198 Commits

Author SHA1 Message Date
dependabot[bot]
82b6e86466 build(deps): bump markdown-it-py from 3.0.0 to 4.0.0
Bumps [markdown-it-py](https://github.com/executablebooks/markdown-it-py) from 3.0.0 to 4.0.0.
- [Release notes](https://github.com/executablebooks/markdown-it-py/releases)
- [Changelog](https://github.com/executablebooks/markdown-it-py/blob/master/CHANGELOG.md)
- [Commits](https://github.com/executablebooks/markdown-it-py/compare/v3.0.0...v4.0.0)

---
updated-dependencies:
- dependency-name: markdown-it-py
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-27 21:49:17 +00:00
dependabot[bot]
cdb8bb26a9 build(deps): bump pydantic-settings from 2.10.1 to 2.11.0 (#698)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [pydantic-settings](https://github.com/pydantic/pydantic-settings) from 2.10.1 to 2.11.0.
- [Release notes](https://github.com/pydantic/pydantic-settings/releases)
- [Commits](https://github.com/pydantic/pydantic-settings/compare/2.10.1...v2.11.0)

---
updated-dependencies:
- dependency-name: pydantic-settings
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 21:52:25 +02:00
dependabot[bot]
9a07b010ca build(deps): bump pvlib from 0.13.0 to 0.13.1 (#699)
Bumps [pvlib](https://github.com/pvlib/pvlib-python) from 0.13.0 to 0.13.1.
- [Release notes](https://github.com/pvlib/pvlib-python/releases)
- [Commits](https://github.com/pvlib/pvlib-python/compare/v0.13.0...v0.13.1)

---
updated-dependencies:
- dependency-name: pvlib
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 21:52:01 +02:00
dependabot[bot]
a70a1f5ca0 build(deps): bump python-fasthtml from 0.12.28 to 0.12.29 (#697)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.28 to 0.12.29.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.28...0.12.29)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-23 13:16:58 +02:00
dependabot[bot]
86a8985235 build(deps): bump psutil from 7.0.0 to 7.1.0 (#694)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [psutil](https://github.com/giampaolo/psutil) from 7.0.0 to 7.1.0.
- [Changelog](https://github.com/giampaolo/psutil/blob/master/HISTORY.rst)
- [Commits](https://github.com/giampaolo/psutil/compare/release-7.0.0...release-7.1.0)

---
updated-dependencies:
- dependency-name: psutil
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-21 20:45:11 +02:00
dependabot[bot]
d3ba5bb464 build(deps): bump uvicorn from 0.35.0 to 0.36.0 (#689)
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.35.0 to 0.36.0.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.35.0...0.36.0)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-21 20:36:30 +02:00
dependabot[bot]
5df8a19f13 build(deps-dev): bump mypy from 1.18.1 to 1.18.2 (#690)
Bumps [mypy](https://github.com/python/mypy) from 1.18.1 to 1.18.2.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.18.1...v1.18.2)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.18.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-21 20:36:14 +02:00
dependabot[bot]
482227c0b1 build(deps): bump python-fasthtml from 0.12.27 to 0.12.28 (#691)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.27 to 0.12.28.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.27...0.12.28)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-21 20:35:55 +02:00
dependabot[bot]
0a6a6d5ad8 build(deps): bump monsterui from 1.0.28 to 1.0.29 (#688)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.28 to 1.0.29.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.28...1.0.29)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-16 10:47:45 +02:00
dependabot[bot]
64db2a7c0e build(deps): bump python-fasthtml from 0.12.25 to 0.12.27
Some checks failed
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.25 to 0.12.27.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.25...0.12.27)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-14 22:26:17 +00:00
dependabot[bot]
ff713ca854 build(deps-dev): bump types-requests
Bumps [types-requests](https://github.com/typeshed-internal/stub_uploader) from 2.32.4.20250809 to 2.32.4.20250913.
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.32.4.20250913
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-14 22:14:53 +00:00
dependabot[bot]
2ec54e3a99 build(deps): bump pydantic from 2.11.7 to 2.11.9
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.11.7 to 2.11.9.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/v2.11.9/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.11.7...v2.11.9)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.11.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-14 22:13:10 +00:00
dependabot[bot]
e3f271d1f3 build(deps): bump timezonefinder from 7.0.1 to 7.0.2
Bumps [timezonefinder](https://github.com/jannikmi/timezonefinder) from 7.0.1 to 7.0.2.
- [Release notes](https://github.com/jannikmi/timezonefinder/releases)
- [Changelog](https://github.com/jannikmi/timezonefinder/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jannikmi/timezonefinder/compare/7.0.1...7.0.2)

---
updated-dependencies:
- dependency-name: timezonefinder
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-14 22:12:48 +00:00
dependabot[bot]
3c0d0b32df build(deps): bump cachebox from 5.0.1 to 5.0.2
Bumps [cachebox](https://github.com/awolverp/cachebox) from 5.0.1 to 5.0.2.
- [Release notes](https://github.com/awolverp/cachebox/releases)
- [Changelog](https://github.com/awolverp/cachebox/blob/main/CHANGELOG.md)
- [Commits](https://github.com/awolverp/cachebox/compare/v5.0.1...v5.0.2)

---
updated-dependencies:
- dependency-name: cachebox
  dependency-version: 5.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-14 22:05:30 +00:00
dependabot[bot]
e8086defd2 build(deps-dev): bump mypy from 1.17.1 to 1.18.1
Bumps [mypy](https://github.com/python/mypy) from 1.17.1 to 1.18.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.17.1...v1.18.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.18.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-14 22:05:09 +00:00
dependabot[bot]
559699cdad build(deps): bump monsterui from 1.0.26 to 1.0.28
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.26 to 1.0.28.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.26...1.0.28)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-14 22:04:33 +00:00
boerni
a4cce85ebd add configurable VAT rate for electricity price calculations (#680)
* add configurable VAT rate for electricity price calculations

* add VAT rate configuration for electricity price calculations in docs

* added vat_rate

* fix: Format VAT rate field definition according to ruff formatting standards

---------

Co-authored-by: Börni <kontakt@bernhardhientz.com>
2025-09-14 23:53:41 +02:00
dependabot[bot]
d362aa7298 build(deps): bump scikit-learn from 1.7.1 to 1.7.2 (#679)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [scikit-learn](https://github.com/scikit-learn/scikit-learn) from 1.7.1 to 1.7.2.
- [Release notes](https://github.com/scikit-learn/scikit-learn/releases)
- [Commits](https://github.com/scikit-learn/scikit-learn/compare/1.7.1...1.7.2)

---
updated-dependencies:
- dependency-name: scikit-learn
  dependency-version: 1.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 09:07:19 +02:00
dependabot[bot]
1f610ce21c build(deps-dev): bump pytest-cov from 6.3.0 to 7.0.0 (#678)
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 6.3.0 to 7.0.0.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v6.3.0...v7.0.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 08:56:59 +02:00
dependabot[bot]
c6884fb54c build(deps): bump numpy from 2.3.2 to 2.3.3 (#677)
Bumps [numpy](https://github.com/numpy/numpy) from 2.3.2 to 2.3.3.
- [Release notes](https://github.com/numpy/numpy/releases)
- [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst)
- [Commits](https://github.com/numpy/numpy/compare/v2.3.2...v2.3.3)

---
updated-dependencies:
- dependency-name: numpy
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 08:56:47 +02:00
7tobias
340ca49957 Fix horizon validation for non-integer angle divisions (#665)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Fixes #647 - Change azimuthFrom/azimuthTo to float type to handle horizon counts that don't divide evenly into 360°

Co-authored-by: Tobias Welz <tobias.wizneteu@gmail.com>
2025-09-09 10:50:14 +02:00
dependabot[bot]
09ad75d47d build(deps-dev): bump pytest from 8.4.1 to 8.4.2 (#671)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.4.1 to 8.4.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.4.1...8.4.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 8.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-07 16:29:42 +02:00
dependabot[bot]
5d1962aeab build(deps-dev): bump pytest-cov from 6.2.1 to 6.3.0 (#672)
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 6.2.1 to 6.3.0.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v6.2.1...v6.3.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-version: 6.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-07 16:18:30 +02:00
dependabot[bot]
b8dc4363ed build(deps): bump monsterui from 1.0.25 to 1.0.26 (#670)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.25 to 1.0.26.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.25...1.0.26)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-31 21:37:34 +02:00
dependabot[bot]
22809c1e9b build(deps): bump matplotlib from 3.10.5 to 3.10.6 (#669)
Bumps [matplotlib](https://github.com/matplotlib/matplotlib) from 3.10.5 to 3.10.6.
- [Release notes](https://github.com/matplotlib/matplotlib/releases)
- [Commits](https://github.com/matplotlib/matplotlib/compare/v3.10.5...v3.10.6)

---
updated-dependencies:
- dependency-name: matplotlib
  dependency-version: 3.10.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-31 21:37:17 +02:00
dependabot[bot]
e007a60c60 build(deps-dev): bump pandas-stubs from 2.3.0.250703 to 2.3.2.250827 (#667)
Bumps [pandas-stubs](https://github.com/pandas-dev/pandas-stubs) from 2.3.0.250703 to 2.3.2.250827.
- [Changelog](https://github.com/pandas-dev/pandas-stubs/blob/main/docs/release_procedure.md)
- [Commits](https://github.com/pandas-dev/pandas-stubs/compare/v2.3.0.250703...v2.3.2.250827)

---
updated-dependencies:
- dependency-name: pandas-stubs
  dependency-version: 2.3.2.250827
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-31 21:29:48 +02:00
dependabot[bot]
efd115e8d4 build(deps): bump bokeh from 3.7.3 to 3.8.0 (#668)
Bumps [bokeh](https://github.com/bokeh/bokeh) from 3.7.3 to 3.8.0.
- [Changelog](https://github.com/bokeh/bokeh/blob/branch-3.9/docs/CHANGELOG)
- [Commits](https://github.com/bokeh/bokeh/compare/3.7.3...3.8.0)

---
updated-dependencies:
- dependency-name: bokeh
  dependency-version: 3.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-31 21:29:20 +02:00
7tobias
5881c1459a docs: Add Docker volume mount configuration instructions (#662)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
- Add documentation explaining the config file requirement when mounting volumes
- Include example EOS.config.json with required 0.0.0.0 host bindings
- Provide commented-out volume mount examples for easy user reference

This addresses issue #661 where users experience connection issues when
mounting local directories without proper configuration.

Co-authored-by: Tobias Welz <tobias.wizneteu@gmail.com>
2025-08-26 23:32:37 +02:00
dependabot[bot]
45a5ec9b66 build(deps): bump platformdirs from 4.3.8 to 4.4.0 (#664)
Bumps [platformdirs](https://github.com/tox-dev/platformdirs) from 4.3.8 to 4.4.0.
- [Release notes](https://github.com/tox-dev/platformdirs/releases)
- [Changelog](https://github.com/tox-dev/platformdirs/blob/main/CHANGES.rst)
- [Commits](https://github.com/tox-dev/platformdirs/compare/4.3.8...4.4.0)

---
updated-dependencies:
- dependency-name: platformdirs
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 21:38:09 +02:00
dependabot[bot]
4c13d561a2 build(deps): bump pandas from 2.3.1 to 2.3.2 (#663)
Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v2.3.1...v2.3.2)

---
updated-dependencies:
- dependency-name: pandas
  dependency-version: 2.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 21:28:57 +02:00
dependabot[bot]
b34c528609 build(deps): bump python-fasthtml from 0.12.24 to 0.12.25 (#660)
Some checks failed
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.24 to 0.12.25.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.24...0.12.25)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 17:10:58 +02:00
dependabot[bot]
7d18c3940e build(deps): bump monsterui from 1.0.24 to 1.0.25 (#659)
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.24 to 1.0.25.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.24...1.0.25)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 17:02:34 +02:00
7tobias
decae10434 fix: handle float values in userhorizon configuration (#657)
Replace int() with round() when converting userhorizon values to properly
handle float values in the configuration. This prevents validation errors
when users provide horizon angles with decimal precision.

The Akkudoktor API expects integer values, so rounding to the nearest
integer maintains compatibility while accepting float inputs.

Fixes #647

Co-authored-by: Tobias Welz <tobias-wizneteu@gmail.com>
2025-08-22 16:41:40 +02:00
7tobias
cacb21529b fix(docker): make EOSDash accessible in Docker containers (#656)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Fixes the issue where EOSDash is not accessible when running EOS in a
Docker container. The problem was that EOSDash was binding to 127.0.0.1
(localhost) by default, making it inaccessible from outside the container.

The fix adds a minimal default configuration file during the Docker image
build that sets both the EOS server and EOSDash to bind to 0.0.0.0,
allowing external access while maintaining security through Docker's
network isolation.

- Add default EOS.config.json in Dockerfile with server bindings set to 0.0.0.0
- No changes required to docker-compose.yaml
- Ensures EOSDash works out of the box for Docker users

Fixes: #629
Closes: https://github.com/Akkudoktor-EOS/EOS/issues/629

Co-authored-by: Tobias Welz <tobias.welz@wiznet.eu>
2025-08-21 15:30:27 +02:00
dependabot[bot]
eed7c9ce58 build(deps-dev): bump pymarkdownlnt from 0.9.31 to 0.9.32 (#655)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [pymarkdownlnt](https://github.com/jackdewinter/pymarkdown) from 0.9.31 to 0.9.32.
- [Release notes](https://github.com/jackdewinter/pymarkdown/releases)
- [Changelog](https://github.com/jackdewinter/pymarkdown/blob/main/changelog.md)
- [Commits](https://github.com/jackdewinter/pymarkdown/compare/v0.9.31...v0.9.32)

---
updated-dependencies:
- dependency-name: pymarkdownlnt
  dependency-version: 0.9.32
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-19 15:49:43 +02:00
dependabot[bot]
4a87c67909 build(deps): bump numpydantic from 1.6.10 to 1.6.11 (#654)
Bumps [numpydantic](https://github.com/p2p-ld/numpydantic) from 1.6.10 to 1.6.11.
- [Release notes](https://github.com/p2p-ld/numpydantic/releases)
- [Changelog](https://github.com/p2p-ld/numpydantic/blob/main/docs/changelog.md)
- [Commits](https://github.com/p2p-ld/numpydantic/compare/v1.6.10...v1.6.11)

---
updated-dependencies:
- dependency-name: numpydantic
  dependency-version: 1.6.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-19 15:49:09 +02:00
dependabot[bot]
8319fed71c build(deps): bump requests from 2.32.4 to 2.32.5 (#652)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Bumps [requests](https://github.com/psf/requests) from 2.32.4 to 2.32.5.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.4...v2.32.5)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-19 01:03:03 +02:00
dependabot[bot]
82c36b1ecd build(deps): bump monsterui from 1.0.23 to 1.0.24 (#651)
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.23 to 1.0.24.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.23...1.0.24)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-19 01:01:43 +02:00
dependabot[bot]
0378fa0f52 build(deps-dev): bump types-requests (#650)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [types-requests](https://github.com/typeshed-internal/stub_uploader) from 2.32.4.20250611 to 2.32.4.20250809.
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.32.4.20250809
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 18:35:04 +02:00
dependabot[bot]
f79ec947d6 build(deps): bump numpydantic from 1.6.9 to 1.6.10 (#644)
Bumps [numpydantic](https://github.com/p2p-ld/numpydantic) from 1.6.9 to 1.6.10.
- [Release notes](https://github.com/p2p-ld/numpydantic/releases)
- [Changelog](https://github.com/p2p-ld/numpydantic/blob/main/docs/changelog.md)
- [Commits](https://github.com/p2p-ld/numpydantic/compare/v1.6.9...v1.6.10)

---
updated-dependencies:
- dependency-name: numpydantic
  dependency-version: 1.6.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 18:22:08 +02:00
dependabot[bot]
a912c22752 build(deps): bump python-fasthtml from 0.12.23 to 0.12.24 (#645)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.23 to 0.12.24.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.23...0.12.24)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 17:59:06 +02:00
dependabot[bot]
370975fc11 build(deps): bump mdit-py-plugins from 0.4.2 to 0.5.0 (#648)
Bumps [mdit-py-plugins](https://github.com/executablebooks/mdit-py-plugins) from 0.4.2 to 0.5.0.
- [Release notes](https://github.com/executablebooks/mdit-py-plugins/releases)
- [Changelog](https://github.com/executablebooks/mdit-py-plugins/blob/master/CHANGELOG.md)
- [Commits](https://github.com/executablebooks/mdit-py-plugins/compare/v0.4.2...v0.5.0)

---
updated-dependencies:
- dependency-name: mdit-py-plugins
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 17:58:44 +02:00
dependabot[bot]
ec8b226ef4 build(deps): bump monsterui from 1.0.21 to 1.0.23 (#643)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.21 to 1.0.23.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.21...1.0.23)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 12:02:34 +02:00
dependabot[bot]
33c4a736fe build(deps): bump python-fasthtml from 0.12.22 to 0.12.23 (#642)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.22 to 0.12.23.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.22...0.12.23)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 11:06:00 +02:00
dependabot[bot]
02fac1229d build(deps): bump matplotlib from 3.10.3 to 3.10.5 (#641)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [matplotlib](https://github.com/matplotlib/matplotlib) from 3.10.3 to 3.10.5.
- [Release notes](https://github.com/matplotlib/matplotlib/releases)
- [Commits](https://github.com/matplotlib/matplotlib/compare/v3.10.3...v3.10.5)

---
updated-dependencies:
- dependency-name: matplotlib
  dependency-version: 3.10.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 00:51:43 +02:00
dependabot[bot]
4cc2ae0fb8 build(deps-dev): bump mypy from 1.17.0 to 1.17.1 (#640)
Bumps [mypy](https://github.com/python/mypy) from 1.17.0 to 1.17.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.17.0...v1.17.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.17.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 00:51:27 +02:00
dependabot[bot]
e8af112859 build(deps-dev): bump gitpython from 3.1.44 to 3.1.45 (#639)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.45
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 15:01:58 +02:00
dependabot[bot]
fde5758b92 build(deps): bump numpy from 2.3.1 to 2.3.2 (#636)
Bumps [numpy](https://github.com/numpy/numpy) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/numpy/numpy/releases)
- [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst)
- [Commits](https://github.com/numpy/numpy/compare/v2.3.1...v2.3.2)

---
updated-dependencies:
- dependency-name: numpy
  dependency-version: 2.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 15:01:40 +02:00
dependabot[bot]
d5b0a2bc78 build(deps): bump python-fasthtml from 0.12.21 to 0.12.22 (#637)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.21 to 0.12.22.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.21...0.12.22)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 15:01:29 +02:00
dependabot[bot]
d09fa40507 build(deps): bump timezonefinder from 6.6.2 to 7.0.1 (#638)
Bumps [timezonefinder](https://github.com/jannikmi/timezonefinder) from 6.6.2 to 7.0.1.
- [Release notes](https://github.com/jannikmi/timezonefinder/releases)
- [Changelog](https://github.com/jannikmi/timezonefinder/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jannikmi/timezonefinder/compare/6.6.2...7.0.1)

---
updated-dependencies:
- dependency-name: timezonefinder
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 15:01:03 +02:00
dependabot[bot]
0c8a9efb65 build(deps): bump timezonefinder from 6.5.9 to 6.6.2 (#624)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [timezonefinder](https://github.com/jannikmi/timezonefinder) from 6.5.9 to 6.6.2.
- [Release notes](https://github.com/jannikmi/timezonefinder/releases)
- [Changelog](https://github.com/jannikmi/timezonefinder/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jannikmi/timezonefinder/compare/6.5.9...6.6.2)

---
updated-dependencies:
- dependency-name: timezonefinder
  dependency-version: 6.6.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-19 15:12:32 +02:00
dependabot[bot]
dbed3d3884 build(deps): bump scikit-learn from 1.7.0 to 1.7.1 (#625)
Bumps [scikit-learn](https://github.com/scikit-learn/scikit-learn) from 1.7.0 to 1.7.1.
- [Release notes](https://github.com/scikit-learn/scikit-learn/releases)
- [Commits](https://github.com/scikit-learn/scikit-learn/compare/1.7.0...1.7.1)

---
updated-dependencies:
- dependency-name: scikit-learn
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-19 14:54:37 +02:00
redmoon2711
8e69caba73 feat(VRM forecast): add load and pv forecast by VRM API (#611)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Add support for fetching forecasts from the VRM API by Victron Energy. Retrieve forecasts for PV generation and total load of a Victron system from the internet. 

Tests for the new modules have been added, and the documentation has been updated accordingly.

Signed-off-by: redmoon2711 <redmoon2711@gmx.de>
2025-07-19 08:55:16 +02:00
dependabot[bot]
e5500b6857 build(deps-dev): bump pymarkdownlnt from 0.9.30 to 0.9.31 (#619)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
---
updated-dependencies:
- dependency-name: pymarkdownlnt
  dependency-version: 0.9.31
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-15 14:21:58 +02:00
dependabot[bot]
646d134f89 build(deps-dev): bump mypy from 1.16.1 to 1.17.0 (#620)
Bumps [mypy](https://github.com/python/mypy) from 1.16.1 to 1.17.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.16.1...v1.17.0)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.17.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-15 14:21:40 +02:00
dependabot[bot]
08220eed02 build(deps): bump statsmodels from 0.14.4 to 0.14.5 (#615)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [statsmodels](https://github.com/statsmodels/statsmodels) from 0.14.4 to 0.14.5.
- [Release notes](https://github.com/statsmodels/statsmodels/releases)
- [Changelog](https://github.com/statsmodels/statsmodels/blob/main/CHANGES.md)
- [Commits](https://github.com/statsmodels/statsmodels/compare/v0.14.4...v0.14.5)

---
updated-dependencies:
- dependency-name: statsmodels
  dependency-version: 0.14.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-08 12:26:27 +02:00
dependabot[bot]
e4664abd21 build(deps): bump pandas from 2.3.0 to 2.3.1 (#617)
Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.3.0 to 2.3.1.
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v2.3.0...v2.3.1)

---
updated-dependencies:
- dependency-name: pandas
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-08 12:26:03 +02:00
dependabot[bot]
7f0340605f build(deps): bump python-fasthtml from 0.12.20 to 0.12.21 (#613)
Some checks failed
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.20 to 0.12.21.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.20...0.12.21)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-06 14:16:57 +02:00
dependabot[bot]
8970b07e17 build(deps-dev): bump pandas-stubs from 2.2.3.250527 to 2.3.0.250703 (#612)
Bumps [pandas-stubs](https://github.com/pandas-dev/pandas-stubs) from 2.2.3.250527 to 2.3.0.250703.
- [Changelog](https://github.com/pandas-dev/pandas-stubs/blob/main/docs/release_procedure.md)
- [Commits](https://github.com/pandas-dev/pandas-stubs/commits)

---
updated-dependencies:
- dependency-name: pandas-stubs
  dependency-version: 2.3.0.250703
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-06 14:16:43 +02:00
dependabot[bot]
bfb13c68da build(deps): bump pydantic-settings from 2.10.0 to 2.10.1 (#609)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
---
updated-dependencies:
- dependency-name: pydantic-settings
  dependency-version: 2.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-06 13:09:51 +02:00
dependabot[bot]
1e5b04d006 build(deps): bump uvicorn from 0.34.3 to 0.35.0 (#610)
---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-06 13:09:32 +02:00
dependabot[bot]
d823c7916f build(deps): bump fastapi[standard] from 0.115.13 to 0.115.14 (#608)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [fastapi[standard]](https://github.com/fastapi/fastapi) from 0.115.13 to 0.115.14.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.115.13...0.115.14)

---
updated-dependencies:
- dependency-name: fastapi[standard]
  dependency-version: 0.115.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 11:26:38 +02:00
redmoon2711
ba4f5ecbb5 Optimization of the Inverter class to speed up scr calculation (#607)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
* fix(ElecPriceEnergyCharts): get history series, update docs

Signed-off-by: redmoon2711 <redmoon2711@gmx.de>

* feat(inverter): using lookup table for calculate scr

Signed-off-by: redmoon2711 <redmoon2711@gmx.de>

---------

Signed-off-by: redmoon2711 <redmoon2711@gmx.de>
2025-06-25 21:13:11 +02:00
redmoon2711
eb23123bba fix(ElecPriceEnergyCharts): get history series, update docs (#606)
Signed-off-by: redmoon2711 <redmoon2711@gmx.de>
2025-06-25 08:26:15 +02:00
dependabot[bot]
eacd9010e1 build(deps): bump pydantic-settings from 2.9.1 to 2.10.0 (#605)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [pydantic-settings](https://github.com/pydantic/pydantic-settings) from 2.9.1 to 2.10.0.
- [Release notes](https://github.com/pydantic/pydantic-settings/releases)
- [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.9.1...2.10.0)

---
updated-dependencies:
- dependency-name: pydantic-settings
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 14:44:52 +02:00
redmoon2711
8c56410338 Add new electricity price provider: Energy-Charts #381 (#590)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
* feat(ElecPriceEnergyCharts): Add new electricity price provider: Energy-Charts

* feat(ElecPriceEnergyCharts): update data only if needed

* test(elecpriceforecast): add test for energycharts

* docs(predictions.md): add ElecPriceEnergyCharts Provider

Signed-off-by: redmoon2711 <redmoon2711@gmx.de>
2025-06-23 07:29:33 +02:00
dependabot[bot]
9e789e1786 build(deps): bump bokeh from 3.7.2 to 3.7.3 (#603)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [bokeh](https://github.com/bokeh/bokeh) from 3.7.2 to 3.7.3.
- [Changelog](https://github.com/bokeh/bokeh/blob/3.7.3/docs/CHANGELOG)
- [Commits](https://github.com/bokeh/bokeh/compare/3.7.2...3.7.3)

---
updated-dependencies:
- dependency-name: bokeh
  dependency-version: 3.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-22 00:33:06 +02:00
dependabot[bot]
ca19534e4e build(deps): bump numpy from 2.3.0 to 2.3.1 (#604)
Bumps [numpy](https://github.com/numpy/numpy) from 2.3.0 to 2.3.1.
- [Release notes](https://github.com/numpy/numpy/releases)
- [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst)
- [Commits](https://github.com/numpy/numpy/compare/v2.3.0...v2.3.1)

---
updated-dependencies:
- dependency-name: numpy
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-22 00:32:50 +02:00
dependabot[bot]
066fb89d6e build(deps-dev): bump pytest from 8.4.0 to 8.4.1 (#599)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.4.0 to 8.4.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.4.0...8.4.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 8.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 15:12:27 +02:00
dependabot[bot]
b533b396e7 build(deps): bump python-fasthtml from 0.12.19 to 0.12.20 (#600)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.19 to 0.12.20.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.19...0.12.20)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 15:12:00 +02:00
dependabot[bot]
5fc86e517d build(deps): bump pvlib from 0.12.0 to 0.13.0 (#598)
Bumps [pvlib](https://github.com/pvlib/pvlib-python) from 0.12.0 to 0.13.0.
- [Release notes](https://github.com/pvlib/pvlib-python/releases)
- [Commits](https://github.com/pvlib/pvlib-python/compare/v0.12.0...v0.13.0)

---
updated-dependencies:
- dependency-name: pvlib
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 14:34:00 +02:00
dependabot[bot]
aa8520dfc3 build(deps): bump fastapi[standard] from 0.115.12 to 0.115.13 (#601)
Bumps [fastapi[standard]](https://github.com/fastapi/fastapi) from 0.115.12 to 0.115.13.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.115.12...0.115.13)

---
updated-dependencies:
- dependency-name: fastapi[standard]
  dependency-version: 0.115.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 14:32:14 +02:00
dependabot[bot]
cb23999f5b build(deps-dev): bump mypy from 1.16.0 to 1.16.1 (#602)
Bumps [mypy](https://github.com/python/mypy) from 1.16.0 to 1.16.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.16.0...v1.16.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.16.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 14:31:37 +02:00
dependabot[bot]
fac74ee92e build(deps-dev): bump types-requests (#594)
Bumps [types-requests](https://github.com/typeshed-internal/stub_uploader) from 2.32.0.20250602 to 2.32.4.20250611.
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.32.4.20250611
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 12:33:04 +02:00
dependabot[bot]
a0e5126d07 build(deps): bump numpy from 2.2.6 to 2.3.0 (#591)
Bumps [numpy](https://github.com/numpy/numpy) from 2.2.6 to 2.3.0.
- [Release notes](https://github.com/numpy/numpy/releases)
- [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst)
- [Commits](https://github.com/numpy/numpy/compare/v2.2.6...v2.3.0)

---
updated-dependencies:
- dependency-name: numpy
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 12:32:51 +02:00
dependabot[bot]
83a2b7171f build(deps-dev): bump pytest-cov from 6.1.1 to 6.2.1 (#592)
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 6.1.1 to 6.2.1.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v6.1.1...v6.2.1)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-version: 6.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 12:32:33 +02:00
dependabot[bot]
501c7e4005 build(deps): bump scikit-learn from 1.6.1 to 1.7.0 (#595)
Bumps [scikit-learn](https://github.com/scikit-learn/scikit-learn) from 1.6.1 to 1.7.0.
- [Release notes](https://github.com/scikit-learn/scikit-learn/releases)
- [Commits](https://github.com/scikit-learn/scikit-learn/compare/1.6.1...1.7.0)

---
updated-dependencies:
- dependency-name: scikit-learn
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 12:32:10 +02:00
dependabot[bot]
af3740ac49 build(deps): bump pydantic from 2.11.5 to 2.11.7 (#593)
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.11.5 to 2.11.7.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.11.5...v2.11.7)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.11.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 11:18:07 +02:00
Bobby Noelte
bd38b3c5ef fix: logging, prediction update, multiple bugs (#584)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
* Fix logging configuration issues that made logging stop operation. Switch to Loguru
  logging (from Python logging). Enable console and file logging with different log levels.
  Add logging documentation.

* Fix logging configuration and EOS configuration out of sync. Added tracking support
  for nested value updates of Pydantic models. This used to update the logging configuration
  when the EOS configurationm for logging is changed. Should keep logging config and EOS
  config in sync as long as all changes to the EOS logging configuration are done by
  set_nested_value(), which is the case for the REST API.

* Fix energy management task looping endlessly after the second update when trying to update
  the last_update datetime.

* Fix get_nested_value() to correctly take values from the dicts in a Pydantic model instance.

* Fix usage of model classes instead of model instances in nested value access when evaluation
  the value type that is associated to each key.

* Fix illegal json format in prediction documentation for PVForecastAkkudoktor provider.

* Fix documentation qirks and add EOS Connect to integrations.

* Support deprecated fields in configuration in documentation generation and EOSdash.

* Enhance EOSdash demo to show BrightSky humidity data (that is often missing)

* Update documentation reference to German EOS installation videos.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-06-10 22:00:28 +02:00
dependabot[bot]
9d46f3c08e build(deps): bump pandas from 2.2.3 to 2.3.0 (#585)
Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.2.3 to 2.3.0.
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v2.2.3...v2.3.0)

---
updated-dependencies:
- dependency-name: pandas
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 08:36:18 +02:00
dependabot[bot]
94ebcbf6d2 build(deps): bump requests from 2.32.3 to 2.32.4 (#586)
Bumps [requests](https://github.com/psf/requests) from 2.32.3 to 2.32.4.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.3...v2.32.4)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 08:27:09 +02:00
dependabot[bot]
98738a16c9 build(deps-dev): bump pytest from 8.3.5 to 8.4.0 (#577)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.5 to 8.4.0.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.5...8.4.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 8.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-03 09:57:15 +02:00
dependabot[bot]
50fec138c9 build(deps-dev): bump types-requests (#576)
Bumps [types-requests](https://github.com/typeshed-internal/stub_uploader) from 2.32.0.20250515 to 2.32.0.20250602.
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.32.0.20250602
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-03 08:31:16 +02:00
dependabot[bot]
77997e2720 build(deps): bump uvicorn from 0.34.2 to 0.34.3 (#578)
Bumps [uvicorn](https://github.com/encode/uvicorn) from 0.34.2 to 0.34.3.
- [Release notes](https://github.com/encode/uvicorn/releases)
- [Changelog](https://github.com/encode/uvicorn/blob/master/docs/release-notes.md)
- [Commits](https://github.com/encode/uvicorn/compare/0.34.2...0.34.3)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.34.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-03 08:30:57 +02:00
Bobby Noelte
3421b2303b ci(ruff): add bandit checks (#575)
Added bandit checks to continuous integration.

Updated sources to pass bandit checks:
- replaced asserts
- added timeouts to requests
- added checks for process command execution
- changed to 127.0.0.1 as default IP address for EOS and EOSdash for security reasons

Added a rudimentary check for outdated config files.

BREAKING CHANGE: Default IP address for EOS and EOSdash changed to 127.0.0.1

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-06-03 08:30:37 +02:00
rompic
aa39ff475c fix: add required fields to example optimization request (#574)
* fix: add required fields to example optimization request
* fix: documentatin of parameters
2025-06-02 10:47:02 +02:00
dependabot[bot]
163c87ca3d build(deps-dev): bump pandas-stubs from 2.2.3.250308 to 2.2.3.250527 (#570)
Bumps [pandas-stubs](https://github.com/pandas-dev/pandas-stubs) from 2.2.3.250308 to 2.2.3.250527.
- [Changelog](https://github.com/pandas-dev/pandas-stubs/blob/main/docs/release_procedure.md)
- [Commits](https://github.com/pandas-dev/pandas-stubs/compare/v2.2.3.250308...v2.2.3.250527)

---
updated-dependencies:
- dependency-name: pandas-stubs
  dependency-version: 2.2.3.250527
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-31 11:17:54 +02:00
dependabot[bot]
32948dbf16 build(deps): bump python-fasthtml from 0.12.18 to 0.12.19 (#571)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.18 to 0.12.19.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.18...0.12.19)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-31 11:17:37 +02:00
dependabot[bot]
6e7ef76f7e build(deps-dev): bump mypy from 1.15.0 to 1.16.0 (#572)
Bumps [mypy](https://github.com/python/mypy) from 1.15.0 to 1.16.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.16.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-30 22:12:33 +02:00
dependabot[bot]
f2ff3ef168 build(deps): bump monsterui from 1.0.20 to 1.0.21 (#573)
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.20 to 1.0.21.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.20...1.0.21)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-30 22:11:42 +02:00
rompic
e413543222 fix: pvforecast fails when there is only a single plane (#569)
* fix: pvforecast fails when there is only a single plane

* fix: formatting

* fix: formatting

* fix: add type annotations

* add testdata and validation test for single plane

* fix: formatting
2025-05-30 21:55:45 +02:00
rompic
058356d1b8 fix: delete empty inverter from testdata optimize_input_2.json (#568) 2025-05-30 10:40:45 +02:00
Bobby Noelte
3ec36e0932 fix: azimuth setting of pvforecastakkudoktor provider (#567)
EOS now enforces the general azimuth definition as e.g. defined in ISO 19111:
north=0, east=90, south=180, west=270. This is the convention that is and was
in the EOS documentation.

As the PV forecast of akkudoktor.net follows a different convention
(north=+-180, east=-90, south=0, west=90) the values from EOS are now converted
before the request is sent to akkudoktor.net.

BREAKING CHANGE: Azimuth configurations that followed the PVForecastAkkudoktor convention
(north=+-180, east=-90, south=0, west=90) must be converted to the general azimuth definition:
north=0, east=90, south=180, west=270.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-05-28 20:42:43 +02:00
rompic
46e078fce1 chore: typos and consistant spelling of Home Assistant (#563) 2025-05-26 21:48:50 +02:00
dependabot[bot]
f88260dc3f build(deps-dev): bump pymarkdownlnt from 0.9.29 to 0.9.30 (#560)
Bumps [pymarkdownlnt](https://github.com/jackdewinter/pymarkdown) from 0.9.29 to 0.9.30.
- [Release notes](https://github.com/jackdewinter/pymarkdown/releases)
- [Changelog](https://github.com/jackdewinter/pymarkdown/blob/main/changelog.md)
- [Commits](https://github.com/jackdewinter/pymarkdown/compare/v0.9.29...v0.9.30)

---
updated-dependencies:
- dependency-name: pymarkdownlnt
  dependency-version: 0.9.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-25 22:37:20 +02:00
dependabot[bot]
289b96ac3d build(deps): bump pydantic from 2.11.4 to 2.11.5 (#559)
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.11.4 to 2.11.5.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.11.4...v2.11.5)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.11.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-25 22:36:35 +02:00
Bobby Noelte
9959cb14a4 fix: BrightSky with None humidity data (#555)
Make the BrightSky weather forecast more robust for the case that BrightSky
does not provide relative humidity data for a certain location.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-05-22 11:15:48 +02:00
dependabot[bot]
ccd2f5307c build(deps): bump python-fasthtml from 0.12.16 to 0.12.18 (#554)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.16 to 0.12.18.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.16...0.12.18)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 11:09:13 +02:00
dependabot[bot]
ce90a5bbca build(deps): bump numpy from 2.2.5 to 2.2.6 (#553)
Bumps [numpy](https://github.com/numpy/numpy) from 2.2.5 to 2.2.6.
- [Release notes](https://github.com/numpy/numpy/releases)
- [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst)
- [Commits](https://github.com/numpy/numpy/compare/v2.2.5...v2.2.6)

---
updated-dependencies:
- dependency-name: numpy
  dependency-version: 2.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 11:08:46 +02:00
dependabot[bot]
df4ab31040 build(deps): bump python-fasthtml from 0.12.15 to 0.12.16 (#550)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.15 to 0.12.16.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.15...0.12.16)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 02:53:41 +02:00
dependabot[bot]
c4b1237ba6 build(deps-dev): bump types-requests (#549)
Bumps [types-requests](https://github.com/typeshed-internal/stub_uploader) from 2.32.0.20250328 to 2.32.0.20250515.
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.32.0.20250515
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 02:34:11 +02:00
dependabot[bot]
62f8aaffc5 build(deps): bump monsterui from 1.0.19 to 1.0.20 (#551)
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.19 to 1.0.20.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.19...1.0.20)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 02:33:33 +02:00
dependabot[bot]
c6aed8d847 build(deps): bump numpydantic from 1.6.8 to 1.6.9 (#548)
Bumps [numpydantic](https://github.com/p2p-ld/numpydantic) from 1.6.8 to 1.6.9.
- [Release notes](https://github.com/p2p-ld/numpydantic/releases)
- [Changelog](https://github.com/p2p-ld/numpydantic/blob/main/docs/changelog.md)
- [Commits](https://github.com/p2p-ld/numpydantic/compare/v1.6.8...v1.6.9)

---
updated-dependencies:
- dependency-name: numpydantic
  dependency-version: 1.6.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-13 08:16:24 +02:00
SchaafAlexander
6c1f728fdf Activation of venv is needed (#546) 2025-05-10 20:30:12 +02:00
dependabot[bot]
481ebd24d7 build(deps): bump matplotlib from 3.10.1 to 3.10.3 (#545)
Bumps [matplotlib](https://github.com/matplotlib/matplotlib) from 3.10.1 to 3.10.3.
- [Release notes](https://github.com/matplotlib/matplotlib/releases)
- [Commits](https://github.com/matplotlib/matplotlib/compare/v3.10.1...v3.10.3)

---
updated-dependencies:
- dependency-name: matplotlib
  dependency-version: 3.10.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-10 02:44:41 +02:00
dependabot[bot]
f1ba9fc2e0 build(deps): bump platformdirs from 4.3.7 to 4.3.8 (#544)
Bumps [platformdirs](https://github.com/tox-dev/platformdirs) from 4.3.7 to 4.3.8.
- [Release notes](https://github.com/tox-dev/platformdirs/releases)
- [Changelog](https://github.com/tox-dev/platformdirs/blob/main/CHANGES.rst)
- [Commits](https://github.com/tox-dev/platformdirs/compare/4.3.7...4.3.8)

---
updated-dependencies:
- dependency-name: platformdirs
  dependency-version: 4.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-10 02:44:21 +02:00
dependabot[bot]
9509451b6c Bump deap from 1.4.2 to 1.4.3 (#541)
Bumps [deap](https://github.com/DEAP/deap) from 1.4.2 to 1.4.3.
- [Changelog](https://github.com/DEAP/deap/blob/master/doc/releases.rst)
- [Commits](https://github.com/DEAP/deap/commits)

---
updated-dependencies:
- dependency-name: deap
  dependency-version: 1.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 11:16:14 +02:00
dependabot[bot]
cecaf4cd16 Bump python-fasthtml from 0.12.14 to 0.12.15 (#542)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.14 to 0.12.15.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.14...0.12.15)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 11:15:42 +02:00
Marco
f8c9fc191a chore: Typos 2025-05-06 09:55:40 +02:00
dependabot[bot]
0f9ad6bf80 Bump pydantic from 2.11.3 to 2.11.4 (#539)
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.11.3 to 2.11.4.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.11.3...v2.11.4)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.11.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-30 23:40:28 +02:00
Bobby Noelte
dac089b320 update(pydantic): Bump pydantic to 2.11.3 (#538)
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-04-30 23:05:44 +02:00
dependabot[bot]
2bfe646996 Bump cachebox from 5.0.0 to 5.0.1 (#536)
Bumps [cachebox](https://github.com/awolverp/cachebox) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/awolverp/cachebox/releases)
- [Changelog](https://github.com/awolverp/cachebox/blob/main/CHANGELOG.md)
- [Commits](https://github.com/awolverp/cachebox/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: cachebox
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-29 10:07:15 +02:00
Dominique Lasserre
c89e8e95fa fix: Catch optimize error and return error message. (#534) 2025-04-23 16:26:35 +02:00
Dominique Lasserre
7ade15e9e3 fix: Circular runtime import Closes #533 (#535) 2025-04-23 16:26:04 +02:00
dependabot[bot]
8b880299b8 Bump cachebox from 4.5.3 to 5.0.0 (#530)
Bumps [cachebox](https://github.com/awolverp/cachebox) from 4.5.3 to 5.0.0.
- [Release notes](https://github.com/awolverp/cachebox/releases)
- [Changelog](https://github.com/awolverp/cachebox/blob/main/CHANGELOG.md)
- [Commits](https://github.com/awolverp/cachebox/compare/v4.5.3...v5.0.0)

---
updated-dependencies:
- dependency-name: cachebox
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 08:45:05 +02:00
dependabot[bot]
e7f83e1bfd Bump numpy from 2.2.4 to 2.2.5 (#531)
Bumps [numpy](https://github.com/numpy/numpy) from 2.2.4 to 2.2.5.
- [Release notes](https://github.com/numpy/numpy/releases)
- [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst)
- [Commits](https://github.com/numpy/numpy/compare/v2.2.4...v2.2.5)

---
updated-dependencies:
- dependency-name: numpy
  dependency-version: 2.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 08:37:11 +02:00
dependabot[bot]
52eb4c195f Bump pydantic-settings from 2.8.1 to 2.9.1 (#532)
Bumps [pydantic-settings](https://github.com/pydantic/pydantic-settings) from 2.8.1 to 2.9.1.
- [Release notes](https://github.com/pydantic/pydantic-settings/releases)
- [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.8.1...v2.9.1)

---
updated-dependencies:
- dependency-name: pydantic-settings
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 08:35:49 +02:00
Christian Hohlfeld
e2a0b8b564 fix(docker): enable BuildKit to support --mount (closes #493) 2025-04-22 01:20:46 +02:00
dependabot[bot]
f7cc43f68c Bump monsterui from 1.0.18 to 1.0.19 (#526)
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.18 to 1.0.19.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.18...1.0.19)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 00:58:28 +02:00
dependabot[bot]
60838ac029 Bump python-fasthtml from 0.12.12 to 0.12.14 (#527)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.12 to 0.12.14.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.12...0.12.14)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 00:44:06 +02:00
dependabot[bot]
cae494458c Bump uvicorn from 0.34.1 to 0.34.2 (#528)
Bumps [uvicorn](https://github.com/encode/uvicorn) from 0.34.1 to 0.34.2.
- [Release notes](https://github.com/encode/uvicorn/releases)
- [Changelog](https://github.com/encode/uvicorn/blob/master/docs/release-notes.md)
- [Commits](https://github.com/encode/uvicorn/compare/0.34.1...0.34.2)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.34.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 00:43:42 +02:00
dependabot[bot]
68e1259ffa Bump pendulum from 3.0.0 to 3.1.0 (#529)
Bumps [pendulum](https://github.com/sdispater/pendulum) from 3.0.0 to 3.1.0.
- [Release notes](https://github.com/sdispater/pendulum/releases)
- [Changelog](https://github.com/python-pendulum/pendulum/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sdispater/pendulum/compare/3.0.0...3.1.0)

---
updated-dependencies:
- dependency-name: pendulum
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 00:43:19 +02:00
Christian Heinrich Hohlfeld
3c12e99970 fix: mitigate ReDoS in to_duration via max input length check (closes #494) (#523) 2025-04-22 00:16:33 +02:00
Christian Heinrich Hohlfeld
63962343d9 ci(docker): add :latest tag on default branch builds (#522) Fixes #499 2025-04-21 19:27:33 +02:00
Bobby Noelte
1e1b7540f3 fix: relax stale issue/pr handling
Wait 90 days until stale indication, wait another 30 days until closing.
Excempt "enhancement" and "feature request" issues from stale handling.
Excempt "in progress" pull reuquests from stale handling.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-04-20 08:07:44 +02:00
dependabot[bot]
a39e0a77a7 Bump uvicorn from 0.34.0 to 0.34.1 (#519)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Bumps [uvicorn](https://github.com/encode/uvicorn) from 0.34.0 to 0.34.1.
- [Release notes](https://github.com/encode/uvicorn/releases)
- [Changelog](https://github.com/encode/uvicorn/blob/master/docs/release-notes.md)
- [Commits](https://github.com/encode/uvicorn/compare/0.34.0...0.34.1)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.34.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-16 09:35:28 +02:00
dependabot[bot]
57f8d3ac17 Bump cachebox from 4.4.2 to 4.5.3 (#514)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Bumps [cachebox](https://github.com/awolverp/cachebox) from 4.4.2 to 4.5.3.
- [Release notes](https://github.com/awolverp/cachebox/releases)
- [Changelog](https://github.com/awolverp/cachebox/blob/main/CHANGELOG.md)
- [Commits](https://github.com/awolverp/cachebox/compare/v4.4.2...v4.5.3)

---
updated-dependencies:
- dependency-name: cachebox
  dependency-version: 4.5.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 11:17:57 +02:00
dependabot[bot]
dc2dbd07b3 Bump bokeh from 3.7.0 to 3.7.2 (#516)
Bumps [bokeh](https://github.com/bokeh/bokeh) from 3.7.0 to 3.7.2.
- [Changelog](https://github.com/bokeh/bokeh/blob/3.7.2/docs/CHANGELOG)
- [Commits](https://github.com/bokeh/bokeh/compare/3.7.0...3.7.2)

---
updated-dependencies:
- dependency-name: bokeh
  dependency-version: 3.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 11:17:41 +02:00
dependabot[bot]
c8e9b5d5c8 Bump monsterui from 1.0.11 to 1.0.18 (#517)
Bumps [monsterui](https://github.com/AnswerDotAI/MonsterUI) from 1.0.11 to 1.0.18.
- [Release notes](https://github.com/AnswerDotAI/MonsterUI/releases)
- [Changelog](https://github.com/AnswerDotAI/MonsterUI/blob/main/CHANGELOG.bak)
- [Commits](https://github.com/AnswerDotAI/MonsterUI/compare/1.0.11...1.0.18)

---
updated-dependencies:
- dependency-name: monsterui
  dependency-version: 1.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 11:16:51 +02:00
dependabot[bot]
876ea65140 Bump pytest-cov from 6.0.0 to 6.1.1 (#518)
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 6.0.0 to 6.1.1.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v6.0.0...v6.1.1)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 11:16:26 +02:00
Dominique Lasserre
cc5fb060ed Merge remote-tracking branch 'origin/main' into feature/config-nested 2025-04-09 00:24:50 +02:00
dependabot[bot]
95c99759ff Bump python-fasthtml from 0.12.6 to 0.12.12 (#508)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.6 to 0.12.12.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.6...0.12.12)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 00:24:36 +02:00
Bobby Noelte
9626bfb32b ci: lint commit messages (#510)
Lint commit messages using gitlint in pre-commit.
Gitlint enforces rules that are configured by .gitlint.

The checks enforce the [`Conventional Commits`](https://www.conventionalcommits.org)
commit message style.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-04-07 22:23:35 +02:00
dependabot[bot]
dc7df99114 Bump python-fasthtml from 0.12.6 to 0.12.12 (#508)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.6 to 0.12.12.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.6...0.12.12)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 17:26:00 +02:00
dependabot[bot]
9f60354e0d Bump pytest-cov from 6.0.0 to 6.1.1 (#509)
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 6.0.0 to 6.1.1.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v6.0.0...v6.1.1)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 17:25:02 +02:00
Dominique Lasserre
0f7837458f Merge remote-tracking branch 'origin/main' into feature/config-nested 2025-04-06 14:51:07 +02:00
Bobby Noelte
4bce51430e Close stale pull requests/ issues after 60 days (#501)
Add GitHub workflow to close requests/issues after 60 days of inactivity.
Requests/ issues may be blocked from closing by adding the In progress label.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-04-06 14:46:52 +02:00
dependabot[bot]
3790a405dc Bump python-fasthtml from 0.12.4 to 0.12.6 (#502)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.4 to 0.12.6.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.4...0.12.6)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-06 14:46:52 +02:00
dependabot[bot]
4ea89b3a5a Bump timezonefinder from 6.5.8 to 6.5.9 (#504)
Bumps [timezonefinder](https://github.com/jannikmi/timezonefinder) from 6.5.8 to 6.5.9.
- [Release notes](https://github.com/jannikmi/timezonefinder/releases)
- [Changelog](https://github.com/jannikmi/timezonefinder/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jannikmi/timezonefinder/compare/6.5.8...6.5.9)

---
updated-dependencies:
- dependency-name: timezonefinder
  dependency-version: 6.5.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-06 14:46:52 +02:00
dependabot[bot]
9979b22a74 Bump types-requests from 2.32.0.20250306 to 2.32.0.20250328 (#505)
Bumps [types-requests](https://github.com/python/typeshed) from 2.32.0.20250306 to 2.32.0.20250328.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.32.0.20250328
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-06 14:46:52 +02:00
Ikko Eltociear Ashimine
8f30d97f86 chore: update weatherclearoutside.py (#496)
reponse -> response
2025-04-06 14:46:52 +02:00
Yunus AYDIN
87ebbf0f08 Fix Cross Site Scripting Issue (#497) 2025-04-06 14:46:52 +02:00
dependabot[bot]
8bdad48823 Bump fastapi[standard] from 0.115.11 to 0.115.12 (#492)
Bumps [fastapi[standard]](https://github.com/fastapi/fastapi) from 0.115.11 to 0.115.12.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.115.11...0.115.12)

---
updated-dependencies:
- dependency-name: fastapi[standard]
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-06 14:46:52 +02:00
thiloms
64732e26e2 CONTRIBUTING.md fix documentation link (404 error) (#484)
Fix take over the same link as in README.md. The current link will end up in HTTP error 404 (not found).
2025-04-06 14:46:52 +02:00
Eric
dd114eee69 Conceptual documentation (#463)
Added new instruction.md, changed index.md accordingly and deleted the no longer needed about.md of new documentation structure.
Refinement of differences to other solutions and features of EOS.

Co-authored-by: Eric Hirsch <git@familie-hirsch.net>
2025-04-06 14:46:52 +02:00
Dominique Lasserre
5eb6d84572 Dockerfile: Set default for EOS_SERVER__EOSDASH_SESSKEY Closes #447 (#467)
* This allows to start the container without any extra settings
   (potentially unsafe).
   It is recommended to set EOS_SERVER__EOSDASH_SESSKEY.
2025-04-06 14:46:52 +02:00
Dominique Lasserre
c46d13731d docker-compose: Expose EOSdash port Closes #447
* Fixes direct EOSdash access on Windows localhost:8504 (required for
   redirect).
2025-04-06 14:46:52 +02:00
Dominique Lasserre
00229c39e4 visualize.py: Support variable remuneration Closes #451 (#459) 2025-04-06 14:46:52 +02:00
Dominique Lasserre
a95d6c939a Windows: Fix EOSdash startup Closes #436 #447 (#450)
* On Windows use 127.0.0.1 as default config host (model defaults) and
   addionally redirect 0.0.0.0 to localhost on Windows (because default
   config file still has 0.0.0.0).
   Use 0.0.0.0 as default otherwise (e.g. Linux/Docker) to allow EOS
   being accessible on local network (not just same host).
   Note: Docs generation on Windows is incompatible with the Github
   pipeline tests. Address this in the nested-config feature branch.
 * Update install/startup instructions as package installation is
   required atm and Docker on Windows has to be accessed at localhost or
   127.0.0.1 even though the server log says 0.0.0.0 (which is required
   to be available outside the container).
 * Fix EOSdash startup with read_only: true (support session key via
   EOS_SERVER__EOSDASH_SESSKEY variable). Backport of feature branch.
 * Remove root_path, causing Windows to fail load swagger UI (/docs).
2025-04-06 14:46:52 +02:00
celle1234
36d794c1c4 visualize: fix timestamps on diagrams (#430) Closes #387
* visualize: fix timestamps on diagrams
* set start time in all graphs to the same beginning hour

---------

Co-authored-by: Normann <github@koldrack.com>
2025-04-06 14:46:43 +02:00
Dominique Lasserre
84598c592c README.md: Add some system requirements (#438) 2025-04-06 12:47:34 +02:00
Theo Weiss
aee4ca2a4f remove excess double quotes in Makefile (#437) 2025-04-06 12:45:31 +02:00
Bobby Noelte
0bda5ba4cc EOSdash: Improve PV forecast configuration. (#500)
* Allow to configure planes and configuration values of planes separatedly.

Make single configuration values for planes explicitly available for configuration.
Still allows to also configure a plane by a whole plane value struct.

* Enhance admin page by file import and export of the EOS configuration

The actual EOS configuration can now be exported to the EOSdash server.
From there it can be also imported. For security reasons only import and export
from/ to a predefined directory on the EOSdash server is possible.

* Improve handling of nested value pathes in pydantic models.

Added separate methods for nested path access (get_nested_value, set_nested_value).
On value setting the missing fields along the nested path are now added automatically
and initialized with default values. Nested path access was before restricted to the
EOS configuration and is now part of the pydantic base model.

* Makefile

Add new target to run rests as CI does on Github. Improve target docs.

* Datetimeutil tests

Prolong acceptable time difference for comparison of approximately equal times in tests.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-04-05 13:08:12 +02:00
Bobby Noelte
b4bc3d0eb2 Close stale pull requests/ issues after 60 days (#501)
Add GitHub workflow to close requests/issues after 60 days of inactivity.
Requests/ issues may be blocked from closing by adding the In progress label.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-04-01 12:44:56 +02:00
dependabot[bot]
a1b19a54d7 Bump python-fasthtml from 0.12.4 to 0.12.6 (#502)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.4 to 0.12.6.
- [Release notes](https://github.com/AnswerDotAI/fasthtml/releases)
- [Changelog](https://github.com/AnswerDotAI/fasthtml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AnswerDotAI/fasthtml/compare/0.12.4...0.12.6)

---
updated-dependencies:
- dependency-name: python-fasthtml
  dependency-version: 0.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 12:44:27 +02:00
dependabot[bot]
4adb408722 Bump timezonefinder from 6.5.8 to 6.5.9 (#504)
Bumps [timezonefinder](https://github.com/jannikmi/timezonefinder) from 6.5.8 to 6.5.9.
- [Release notes](https://github.com/jannikmi/timezonefinder/releases)
- [Changelog](https://github.com/jannikmi/timezonefinder/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jannikmi/timezonefinder/compare/6.5.8...6.5.9)

---
updated-dependencies:
- dependency-name: timezonefinder
  dependency-version: 6.5.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 12:44:01 +02:00
dependabot[bot]
dba6e36f96 Bump types-requests from 2.32.0.20250306 to 2.32.0.20250328 (#505)
Bumps [types-requests](https://github.com/python/typeshed) from 2.32.0.20250306 to 2.32.0.20250328.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.32.0.20250328
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 12:43:27 +02:00
Ikko Eltociear Ashimine
83b6bdbdc6 chore: update weatherclearoutside.py (#496)
reponse -> response
2025-03-29 00:39:30 +01:00
Yunus AYDIN
2468efe604 Fix Cross Site Scripting Issue (#497)
* Update eos.py

* ruff format

* ruff format
2025-03-27 22:22:18 +01:00
Bobby Noelte
e6a8c0508e Fix negative values load mean adjusted (#491)
* Fix negativ values in load_mean_adjusted

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-03-27 21:57:26 +01:00
Bobby Noelte
7aaf193682 EOSdash: Enable EOS configuration by EOSdash. (#477)
Improve config page to edit actual configuration used by EOS.
Add admin page to save the actual configuration to the configuration file.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-03-27 21:53:01 +01:00
dependabot[bot]
7734c9c32f Bump fastapi[standard] from 0.115.11 to 0.115.12 (#492)
Bumps [fastapi[standard]](https://github.com/fastapi/fastapi) from 0.115.11 to 0.115.12.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.115.11...0.115.12)

---
updated-dependencies:
- dependency-name: fastapi[standard]
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 08:16:20 +01:00
Normann
61c5efc74f Update requirements.txt for dev branch 2025-03-24 08:04:12 +01:00
Normann
2c1f16a3fb Update requirements for dev branch (#488)
* Update requirements

* Update requirements-dev.txt

* Update pyproject.toml

* Update README.md
2025-03-23 22:31:49 +01:00
Bobby Noelte
2a5879c177 Add load figure to demo page. (#469)
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-03-02 10:48:34 +01:00
Normann
a7d58eed9a pre-commit update and ignore changes (#461)
* pre-commit autoupdate
* type: ignore changes
* [attr-defined,unused-ignore] usage
2025-02-24 10:00:09 +01:00
Bobby Noelte
1020a46435 Add Markdown linter
Add Markdown linter (pymarkdown) to pre-commit.
Adapt current markdown files to fulfill linter rules.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-02-18 10:26:38 +01:00
Dennis
8258b1cca1 EOF issue in "optimize" documentation 2025-02-18 10:26:38 +01:00
Dennis
afbe50c388 Initial "optimize" documentation 2025-02-18 10:26:38 +01:00
Bobby Noelte
c8cad0f277 Fix BrightSky weather prediction
- Get weather data with fully specified end_date datetime argument to not miss data.
- Make preciptable water records generation robust against missing temperature
  or humidity values.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-02-18 07:04:54 +01:00
Dominique Lasserre
694655311f Workflow: Docker build on all PRs (#429)
* Just amd64 and no push.
2025-02-15 19:48:20 +01:00
Normann
1cd38d93ba moved linkify-it-py 2025-02-15 14:09:53 +01:00
Normann
7dfd50475a Update requirements.txt with linkify-it-py´ 2025-02-15 14:09:53 +01:00
Bobby Noelte
ab6a518b5f Improve EOSdash.
Make EOSdash use UI components from MonsterUI to ease further development.

- Add a first menu with some dummy pages and the configuration page.
- Make the configuration scrollable.
- Add markdown component that uses markdown-it-py (same as used by
  the myth-parser for documentation generation).
- Add bokeh (https://docs.bokeh.org/) component for charts
- Added several prediction charts to demo
- Add a footer that displays connection status with EOS server
- Add logo and favicon

Update EOS server:

- Move error message generation to extra module
- Use redirect instead of proxy

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-02-15 14:09:53 +01:00
Bobby Noelte
80bfe4d0f0 Improve caching. (#431)
* Move the caching module to core.

Add an in memory cache that for caching function and method results
during an energy management run (optimization run). Two decorators
are provided for methods and functions.

* Improve the file cache store by load and save functions.

Make EOS load the cache file store on startup and save it on shutdown.
Add a cyclic task that cleans the cache file store from outdated cache files.

* Improve startup of EOSdash by EOS

Make EOS starting EOSdash adhere to path configuration given in EOS.
The whole environment from EOS is now passed to EOSdash.
Should also prevent test errors due to unwanted/ wrong config file creation.

Both servers now provide a health endpoint that can be used to detect whether
the server is running. This is also used for testing now.

* Improve startup of EOS

EOS now has got an energy management task that runs shortly after startup.
It tries to execute energy management runs with predictions newly fetched
or initialized from cached data on first run.

* Improve shutdown of EOS

EOS has now a shutdown task that shuts EOS down gracefully with some
time delay to allow REST API requests for shutdwon or restart to be fully serviced.

* Improve EMS

Add energy management task for repeated energy management controlled by
startup delay and interval configuration parameters.
Translate EnergieManagementSystem to english EnergyManagement.

* Add administration endpoints

  - endpoints to control caching from REST API.
  - endpoints to control server restart (will not work on Windows) and shutdown from REST API

* Improve doc generation

Use "\n" linenend convention also on Windows when generating doc files.
Replace Windows specific 127.0.0.1 address by standard 0.0.0.0.

* Improve test support (to be able to test caching)

  - Add system test option to pytest for running tests with "real" resources
  - Add new test fixture to start server for test class and test function
  - Make kill signal adapt to Windows/ Linux
  - Use consistently "\n" for lineends when writing text files in  doc test
  - Fix test_logging under Windows
  - Fix conftest config_default_dirs test fixture under Windows

From @Lasall

* Improve Windows support

 - Use 127.0.0.1 as default config host (model defaults) and
   addionally redirect 0.0.0.0 to localhost on Windows (because default
   config file still has 0.0.0.0).
 - Update install/startup instructions as package installation is
   required atm.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-02-12 21:35:51 +01:00
Dominique Lasserre
1a2cb4d37d Fix Python 3.13: classmethod + property unsupported (#448)
* Use own classproperty (don't inherit from property).
 * Config generation: Rename pathlib._local to pathlib
2025-02-11 21:01:45 +01:00
Dominique Lasserre
d05b161e24 Config: Don't fail on config error, fix Windows config dir (#426)
* Support setting logger level (env) before config load.
2025-02-08 00:45:11 +01:00
Dominique Lasserre
da4994ca39 Remove potentially unexpected config update 2025-02-02 10:09:15 +01:00
Dominique Lasserre
94618f5f66 REST: Allow setting single config value
* /v1/config/{path} supports setting single config value (post body). Lists are
   supported as well by using the index:
    - general/latitude (value: 55.55)
    - optimize/ev_available_charge_rates_percent/0 (value: 42)

   Whole tree can be overriden as well (no merge):
    - optimize/ev_available_charge_rates_percent (value: [42, 43, 44]

 * ConfigEOS: Add set_config_value, get_config_value
2025-02-02 10:09:15 +01:00
Normann
1bb74ed836 replacing import logging (#425) 2025-01-27 21:18:15 +01:00
Normann
6743d8df4f Info text for intentional errors Closes #416 (#422)
* info before error
* core.logging usage
2025-01-26 22:42:54 +01:00
Normann
480adf8100 Data prefetch ems for feature (#420)
* Pre-fetch data

* maintanance and extend tests

* comment clean up

* nansum usage (to be save)

* Feature/config nested (#421)

* Nested config, devices registry

 * All config now nested.
    - Use default config from model field default values. If providers
      should be enabled by default, non-empty default config file could
      be provided again.
    - Environment variable support with EOS_ prefix and __ between levels,
      e.g. EOS_SERVER__EOS_SERVER_PORT=8503 where all values are case
      insensitive.
      For more information see:
      https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values
    - Use devices as registry for configured devices. DeviceBase as base
      class with for now just initializion support (in the future expand
      to operations during optimization).
    - Strip down ConfigEOS to the only configuration instance. Reload
      from file or reset to defaults is possible.

 * Fix multi-initialization of derived SingletonMixin classes.

* Documentation: Support nested config

 * Add examples to pydantic models.

* EOSdash: Support nested types

* Rename settings variables (remove prefixes)

* Fix API endpoint

* Fix EOSdash startup (docker)

 * Docker: Copy the same directory structure (src/) to support the
   lifespan startup of EOSdash.
   Use EOS_SERVER_EOSDASH_SESSKEY environment variable to provide
   EOSdash with session key.

* PR review

* PVForecast: planes as nested config (list)

* Update manual documentation for nested config.

 * Add config_file_path, config_folder_path back to general
   (ConfigCommonSettings). Overwrite in docs generation.

* Config: Move lat/long/timezone from prediction to general

* Docs: Add global example documentation.

 * merge_models: Use deecopy to not change input data.

* EOSdash: Sort config by name

* Review comments

* Feature/config nested dependabot req. (#415)

* Bump numpydantic from 1.6.4 to 1.6.7 (#413)

Bumps [numpydantic](https://github.com/p2p-ld/numpydantic) from 1.6.4 to 1.6.7.
- [Release notes](https://github.com/p2p-ld/numpydantic/releases)
- [Changelog](https://github.com/p2p-ld/numpydantic/blob/main/docs/changelog.md)
- [Commits](https://github.com/p2p-ld/numpydantic/compare/v1.6.4...v1.6.7)

---
updated-dependencies:
- dependency-name: numpydantic
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump timezonefinder from 6.5.7 to 6.5.8 (#414)

Bumps [timezonefinder](https://github.com/jannikmi/timezonefinder) from 6.5.7 to 6.5.8.
- [Release notes](https://github.com/jannikmi/timezonefinder/releases)
- [Changelog](https://github.com/jannikmi/timezonefinder/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jannikmi/timezonefinder/compare/6.5.7...6.5.8)

---
updated-dependencies:
- dependency-name: timezonefinder
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump pydantic from 2.10.5 to 2.10.6 (#412)

Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.10.5 to 2.10.6.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.10.5...v2.10.6)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump fastapi[standard] from 0.115.6 to 0.115.7 (#411)

Bumps [fastapi[standard]](https://github.com/fastapi/fastapi) from 0.115.6 to 0.115.7.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.115.6...0.115.7)

---
updated-dependencies:
- dependency-name: fastapi[standard]
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Readme: Add hint for interfering ports on Synology Closes #408 (#419)

* Pics or it didn't happen (#402)

* inverter added

* png creation

* save svg into cache folder

* mypy

* comment

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Dominique Lasserre <lasserre.d@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* inverter, prediction.hours

* self.config.general.data_cache_path

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Dominique Lasserre <lasserre.d@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:12:14 +01:00
Normann
90688a36f2 Pics or it didn't happen (#402)
* inverter added

* png creation

* save svg into cache folder

* mypy

* comment
2025-01-26 18:27:09 +01:00
Dominique Lasserre
6516455071 Readme: Add hint for interfering ports on Synology Closes #408 (#419) 2025-01-26 18:26:46 +01:00
Normann
84683cd195 Feature/config nested dependabot req. (#415)
* Bump numpydantic from 1.6.4 to 1.6.7 (#413)

Bumps [numpydantic](https://github.com/p2p-ld/numpydantic) from 1.6.4 to 1.6.7.
- [Release notes](https://github.com/p2p-ld/numpydantic/releases)
- [Changelog](https://github.com/p2p-ld/numpydantic/blob/main/docs/changelog.md)
- [Commits](https://github.com/p2p-ld/numpydantic/compare/v1.6.4...v1.6.7)

---
updated-dependencies:
- dependency-name: numpydantic
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump timezonefinder from 6.5.7 to 6.5.8 (#414)

Bumps [timezonefinder](https://github.com/jannikmi/timezonefinder) from 6.5.7 to 6.5.8.
- [Release notes](https://github.com/jannikmi/timezonefinder/releases)
- [Changelog](https://github.com/jannikmi/timezonefinder/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jannikmi/timezonefinder/compare/6.5.7...6.5.8)

---
updated-dependencies:
- dependency-name: timezonefinder
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump pydantic from 2.10.5 to 2.10.6 (#412)

Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.10.5 to 2.10.6.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.10.5...v2.10.6)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump fastapi[standard] from 0.115.6 to 0.115.7 (#411)

Bumps [fastapi[standard]](https://github.com/fastapi/fastapi) from 0.115.6 to 0.115.7.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.115.6...0.115.7)

---
updated-dependencies:
- dependency-name: fastapi[standard]
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-25 19:43:43 +01:00
Dominique Lasserre
26762e5e93 Review comments 2025-01-24 21:14:37 +01:00
Dominique Lasserre
56403fe053 EOSdash: Sort config by name 2025-01-24 20:08:53 +01:00
Dominique Lasserre
5bd8321e95 Docs: Add global example documentation.
* merge_models: Use deecopy to not change input data.
2025-01-24 20:08:53 +01:00
Dominique Lasserre
c1dd31528b Config: Move lat/long/timezone from prediction to general 2025-01-24 20:08:53 +01:00
Dominique Lasserre
1658b491d2 Update manual documentation for nested config.
* Add config_file_path, config_folder_path back to general
   (ConfigCommonSettings). Overwrite in docs generation.
2025-01-24 20:08:52 +01:00
Dominique Lasserre
af5e4a753a PVForecast: planes as nested config (list) 2025-01-24 20:08:52 +01:00
Dominique Lasserre
e0b1ece524 PR review 2025-01-24 20:08:50 +01:00
Dominique Lasserre
437d38f508 Fix EOSdash startup (docker)
* Docker: Copy the same directory structure (src/) to support the
   lifespan startup of EOSdash.
   Use EOS_SERVER_EOSDASH_SESSKEY environment variable to provide
   EOSdash with session key.
2025-01-24 20:08:48 +01:00
Dominique Lasserre
95be7b914f Fix API endpoint 2025-01-24 20:07:23 +01:00
Dominique Lasserre
3257dac92b Rename settings variables (remove prefixes) 2025-01-24 20:07:21 +01:00
Dominique Lasserre
1e1bac9fdb EOSdash: Support nested types 2025-01-24 20:06:38 +01:00
Dominique Lasserre
d74a56b75a Documentation: Support nested config
* Add examples to pydantic models.
2025-01-24 20:05:48 +01:00
Dominique Lasserre
be26457563 Nested config, devices registry
* All config now nested.
    - Use default config from model field default values. If providers
      should be enabled by default, non-empty default config file could
      be provided again.
    - Environment variable support with EOS_ prefix and __ between levels,
      e.g. EOS_SERVER__EOS_SERVER_PORT=8503 where all values are case
      insensitive.
      For more information see:
      https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values
    - Use devices as registry for configured devices. DeviceBase as base
      class with for now just initializion support (in the future expand
      to operations during optimization).
    - Strip down ConfigEOS to the only configuration instance. Reload
      from file or reset to defaults is possible.

 * Fix multi-initialization of derived SingletonMixin classes.
2025-01-24 20:05:48 +01:00
157 changed files with 20282 additions and 21544 deletions

View File

@@ -1,8 +1,8 @@
.git/ .git/
.github/ .github/
eos-data/ **/__pycache__/
mariadb-data/ **/*.pyc
test_data/ **/*.egg-info/
.dockerignore .dockerignore
.env .env
.gitignore .gitignore
@@ -12,4 +12,4 @@ LICENSE
Makefile Makefile
NOTICE NOTICE
README.md README.md
.venv .venv/

4
.env
View File

@@ -1,5 +1,5 @@
EOS_VERSION=main EOS_VERSION=main
EOS_PORT=8503 EOS_SERVER__PORT=8503
EOSDASH_PORT=8504 EOS_SERVER__EOSDASH_PORT=8504
PYTHON_VERSION=3.12.6 PYTHON_VERSION=3.12.6

View File

@@ -7,13 +7,11 @@ on:
push: push:
branches: branches:
- 'main' - 'main'
- 'feature/config-overhaul'
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
branches: branches:
- 'main' - '**'
- 'feature/config-overhaul'
env: env:
DOCKERHUB_REPO: akkudoktor/eos DOCKERHUB_REPO: akkudoktor/eos
@@ -197,6 +195,7 @@ jobs:
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
labels: | labels: |
org.opencontainers.image.licenses=${{ env.EOS_LICENSE }} org.opencontainers.image.licenses=${{ env.EOS_LICENSE }}
annotations: | annotations: |

35
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: "Close stale pull requests/issues"
on:
schedule:
- cron: "16 00 * * *"
permissions:
contents: read
jobs:
stale:
name: Find Stale issues and PRs
runs-on: ubuntu-22.04
if: github.repository == 'Akkudoktor-EOS/EOS'
permissions:
pull-requests: write # to comment on stale pull requests
issues: write # to comment on stale issues
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
stale-pr-message: 'This pull request has been marked as stale because it has been open (more
than) 90 days with no activity. Remove the stale label or add a comment saying that you
would like to have the label removed otherwise this pull request will automatically be
closed in 30 days. Note, that you can always re-open a closed pull request at any time.'
stale-issue-message: 'This issue has been marked as stale because it has been open (more
than) 90 days with no activity. Remove the stale label or add a comment saying that you
would like to have the label removed otherwise this issue will automatically be closed in
30 days. Note, that you can always re-open a closed issue at any time.'
days-before-stale: 90
days-before-close: 30
stale-issue-label: 'stale'
stale-pr-label: 'stale'
exempt-pr-labels: 'in progress'
exempt-issue-labels: 'feature request, enhancement'
operations-per-run: 400

5
.gitignore vendored
View File

@@ -179,7 +179,7 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ .idea/
# General # General
.DS_Store .DS_Store
@@ -260,3 +260,6 @@ tests/testdata/new_optimize_result*
tests/testdata/openapi-new.json tests/testdata/openapi-new.json
tests/testdata/openapi-new.md tests/testdata/openapi-new.md
tests/testdata/config-new.md tests/testdata/config-new.md
# FastHTML session key
.sesskey

35
.gitlint Normal file
View File

@@ -0,0 +1,35 @@
[general]
# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
verbosity = 3
regex-style-search=true
# Ignore rules, reference them by id or name (comma-separated)
ignore=title-trailing-punctuation, T3
# Enable specific community contributed rules
contrib=contrib-title-conventional-commits,CC1
# Set the extra-path where gitlint will search for user defined rules
extra-path=scripts/gitlint
[title-max-length]
line-length=80
[title-min-length]
min-length=5
[ignore-by-title]
# Match commit titles starting with "Release"
regex=^Release(.*)
ignore=title-max-length,body-min-length
[ignore-by-body]
# Match commits message bodies that have a line that contains 'release'
regex=(.*)release(.*)
ignore=all
[ignore-by-author-name]
# Match commits by author name (e.g. ignore dependabot commits)
regex=dependabot
ignore=all

View File

@@ -12,12 +12,12 @@ repos:
- id: check-merge-conflict - id: check-merge-conflict
exclude: '\.rst$' # Exclude .rst files exclude: '\.rst$' # Exclude .rst files
- repo: https://github.com/PyCQA/isort - repo: https://github.com/PyCQA/isort
rev: 5.13.2 rev: 6.0.0
hooks: hooks:
- id: isort - id: isort
name: isort name: isort
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8 rev: v0.9.6
hooks: hooks:
# Run the linter and fix simple issues automatically # Run the linter and fix simple issues automatically
- id: ruff - id: ruff
@@ -25,7 +25,7 @@ repos:
# Run the formatter. # Run the formatter.
- id: ruff-format - id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v1.13.0' rev: 'v1.15.0'
hooks: hooks:
- id: mypy - id: mypy
additional_dependencies: additional_dependencies:
@@ -34,7 +34,7 @@ repos:
- "numpy==2.1.3" - "numpy==2.1.3"
pass_filenames: false pass_filenames: false
- repo: https://github.com/jackdewinter/pymarkdown - repo: https://github.com/jackdewinter/pymarkdown
rev: main rev: v0.9.29
hooks: hooks:
- id: pymarkdown - id: pymarkdown
files: ^docs/ files: ^docs/
@@ -42,3 +42,7 @@ repos:
args: args:
- --config=docs/pymarkdown.json - --config=docs/pymarkdown.json
- scan - scan
- repo: https://github.com/jorisroovers/gitlint
rev: v0.19.1
hooks:
- id: gitlint

View File

@@ -21,8 +21,8 @@ There are just too many possibilities and the project would drown in tickets oth
## Code Contributions ## Code Contributions
We welcome code contributions and bug fixes via [Pull Requests](https://github.com/Akkudoktor-EOS/EOS/pulls). We welcome code contributions and bug fixes via [Pull Requests](https://github.com/Akkudoktor-EOS/EOS/pulls).
To make collaboration easier, we require pull requests to pass code style and unit tests. To make collaboration easier, we require pull requests to pass code style, unit tests, and commit
message style checks.
### Setup development environment ### Setup development environment
@@ -60,6 +60,7 @@ To run formatting automatically before every commit:
```bash ```bash
pre-commit install pre-commit install
pre-commit install --hook-type commit-msg
``` ```
Or run them manually: Or run them manually:
@@ -75,3 +76,8 @@ Use `pytest` to run tests locally:
```bash ```bash
python -m pytest -vs --cov src --cov-report term-missing tests/ python -m pytest -vs --cov src --cov-report term-missing tests/
``` ```
### Commit message style
Our commit message checks use [`gitlint`](https://github.com/jorisroovers/gitlint). The checks
enforce the [`Conventional Commits`](https://www.conventionalcommits.org) commit message style.

View File

@@ -1,10 +1,9 @@
# syntax=docker/dockerfile:1.7
ARG PYTHON_VERSION=3.12.7 ARG PYTHON_VERSION=3.12.7
FROM python:${PYTHON_VERSION}-slim FROM python:${PYTHON_VERSION}-slim
LABEL source="https://github.com/Akkudoktor-EOS/EOS" LABEL source="https://github.com/Akkudoktor-EOS/EOS"
ENV VIRTUAL_ENV="/opt/venv"
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
ENV MPLCONFIGDIR="/tmp/mplconfigdir" ENV MPLCONFIGDIR="/tmp/mplconfigdir"
ENV EOS_DIR="/opt/eos" ENV EOS_DIR="/opt/eos"
ENV EOS_CACHE_DIR="${EOS_DIR}/cache" ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
@@ -36,13 +35,25 @@ RUN mkdir -p src && pip install -e .
COPY src src COPY src src
# Create minimal default configuration for Docker to fix EOSDash accessibility (#629)
# This ensures EOSDash binds to 0.0.0.0 instead of 127.0.0.1 in containers
RUN echo '{\n\
"server": {\n\
"host": "0.0.0.0",\n\
"port": 8503,\n\
"startup_eosdash": true,\n\
"eosdash_host": "0.0.0.0",\n\
"eosdash_port": 8504\n\
}\n\
}' > "${EOS_CONFIG_DIR}/EOS.config.json" \
&& chown eos:eos "${EOS_CONFIG_DIR}/EOS.config.json"
USER eos USER eos
ENTRYPOINT [] ENTRYPOINT []
EXPOSE 8503 EXPOSE 8503
EXPOSE 8504 EXPOSE 8504
ENV server_eosdash_host=0.0.0.0
CMD ["python", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"] CMD ["python", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"]
VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"] VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"]

View File

@@ -1,5 +1,5 @@
# Define the targets # Define the targets
.PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format mypy run run-dev .PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format gitlint mypy run run-dev
# Default target # Default target
all: help all: help
@@ -11,6 +11,7 @@ help:
@echo " pip - Install dependencies from requirements.txt." @echo " pip - Install dependencies from requirements.txt."
@echo " pip-dev - Install dependencies from requirements-dev.txt." @echo " pip-dev - Install dependencies from requirements-dev.txt."
@echo " format - Format source code." @echo " format - Format source code."
@echo " gitlint - Lint last commit message."
@echo " mypy - Run mypy." @echo " mypy - Run mypy."
@echo " install - Install EOS in editable form (development mode) into virtual environment." @echo " install - Install EOS in editable form (development mode) into virtual environment."
@echo " docker-run - Run entire setup on docker" @echo " docker-run - Run entire setup on docker"
@@ -19,8 +20,13 @@ help:
@echo " read-docs - Read HTML documentation in your browser." @echo " read-docs - Read HTML documentation in your browser."
@echo " gen-docs - Generate openapi.json and docs/_generated/*." @echo " gen-docs - Generate openapi.json and docs/_generated/*."
@echo " clean-docs - Remove generated documentation." @echo " clean-docs - Remove generated documentation."
@echo " run - Run EOS production server in the virtual environment." @echo " run - Run EOS production server in virtual environment."
@echo " run-dev - Run EOS development server in the virtual environment (automatically reloads)." @echo " run-dev - Run EOS development server in virtual environment (automatically reloads)."
@echo " run-dash - Run EOSdash production server in virtual environment."
@echo " run-dash-dev - Run EOSdash development server in virtual environment (automatically reloads)."
@echo " test - Run tests."
@echo " test-full - Run tests with full optimization."
@echo " test-ci - Run tests as CI does. No user config file allowed."
@echo " dist - Create distribution (in dist/)." @echo " dist - Create distribution (in dist/)."
@echo " clean - Remove generated documentation, distribution and virtual environment." @echo " clean - Remove generated documentation, distribution and virtual environment."
@@ -70,6 +76,11 @@ read-docs: docs
@echo "Read the documentation in your browser" @echo "Read the documentation in your browser"
.venv/bin/python -m webbrowser build/docs/html/index.html .venv/bin/python -m webbrowser build/docs/html/index.html
# Clean Python bytecode
clean-bytecode:
find . -type d -name "__pycache__" -exec rm -r {} +
find . -type f -name "*.pyc" -delete
# Clean target to remove generated documentation and documentation artefacts # Clean target to remove generated documentation and documentation artefacts
clean-docs: clean-docs:
@echo "Searching and deleting all '_autosum' directories in docs..." @echo "Searching and deleting all '_autosum' directories in docs..."
@@ -85,11 +96,19 @@ clean: clean-docs
run: run:
@echo "Starting EOS production server, please wait..." @echo "Starting EOS production server, please wait..."
.venv/bin/python src/akkudoktoreos/server/eos.py .venv/bin/python -m akkudoktoreos.server.eos
run-dev: run-dev:
@echo "Starting EOS development server, please wait..." @echo "Starting EOS development server, please wait..."
.venv/bin/python src/akkudoktoreos/server/eos.py --host localhost --port 8503 --reload true .venv/bin/python -m akkudoktoreos.server.eos --host localhost --port 8503 --reload true
run-dash:
@echo "Starting EOSdash production server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eosdash
run-dash-dev:
@echo "Starting EOSdash development server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --reload true
# Target to setup tests. # Target to setup tests.
test-setup: pip-dev test-setup: pip-dev
@@ -100,6 +119,11 @@ test:
@echo "Running tests..." @echo "Running tests..."
.venv/bin/pytest -vs --cov src --cov-report term-missing .venv/bin/pytest -vs --cov src --cov-report term-missing
# Target to run tests as done by CI on Github.
test-ci:
@echo "Running tests as CI..."
.venv/bin/pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing
# Target to run all tests. # Target to run all tests.
test-full: test-full:
@echo "Running all tests..." @echo "Running all tests..."
@@ -109,6 +133,10 @@ test-full:
format: format:
.venv/bin/pre-commit run --all-files .venv/bin/pre-commit run --all-files
# Target to trigger gitlint using pre-commit for the last commit message
gitlint:
.venv/bin/pre-commit run gitlint --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
# Target to format code. # Target to format code.
mypy: mypy:
.venv/bin/mypy .venv/bin/mypy

View File

@@ -20,7 +20,7 @@ Other architectures (e.g. armv6, armv7) are unsupported for now, because a multi
## Installation ## Installation
Docker images (amd64/aarch64) can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos). The project requires Python 3.11 or newer. Docker images (amd64/aarch64) can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos).
Following sections describe how to locally start the EOS server on `http://localhost:8503`. Following sections describe how to locally start the EOS server on `http://localhost:8503`.
@@ -40,8 +40,9 @@ Windows:
```cmd ```cmd
python -m venv .venv python -m venv .venv
.venv\Scripts\pip install -r requirements.txt .venv\Scripts\Activate
.venv\Scripts\pip install -e . .venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pip install -e .
``` ```
Finally, start the EOS server to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`): Finally, start the EOS server to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`):
@@ -66,6 +67,8 @@ Start EOS with following command to access it at `http://localhost:8503` (API do
docker compose up docker compose up
``` ```
If you are running the EOS container on a system hosting multiple services, such as a Synology NAS, and want to allow external network access to EOS, please ensure that the default exported ports (8503, 8504) are available on the host. On Synology systems, these ports might already be in use (refer to [this guide](https://kb.synology.com/en-me/DSM/tutorial/What_network_ports_are_used_by_Synology_services)). If the ports are occupied, you will need to reconfigure the exported ports accordingly.
## Configuration ## Configuration
This project uses the `EOS.config.json` file to manage configuration settings. This project uses the `EOS.config.json` file to manage configuration settings.

View File

@@ -11,14 +11,35 @@ services:
dockerfile: "Dockerfile" dockerfile: "Dockerfile"
args: args:
PYTHON_VERSION: "${PYTHON_VERSION}" PYTHON_VERSION: "${PYTHON_VERSION}"
env_file:
- .env
environment: environment:
- EOS_CONFIG_DIR=config - EOS_CONFIG_DIR=config
- latitude=52.2
- longitude=13.4
- elecprice_provider=ElecPriceAkkudoktor
- elecprice_charges_kwh=0.21
- EOS_SERVER__EOSDASH_SESSKEY=s3cr3t - EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
- EOS_PREDICTION__LATITUDE=52.2
- EOS_PREDICTION__LONGITUDE=13.4
- EOS_ELECPRICE__PROVIDER=ElecPriceAkkudoktor
- EOS_ELECPRICE__CHARGES_KWH=0.21
ports: ports:
# Configure what ports to expose on host # Configure what ports to expose on host
- "${EOS_PORT}:8503" - "${EOS_SERVER__PORT}:8503"
- "${EOSDASH_PORT}:8504" - "${EOS_SERVER__EOSDASH_PORT}:8504"
# Volume mount configuration (optional)
# IMPORTANT: When mounting local directories, the default config won't be available.
# You must create an EOS.config.json file in your local config directory with:
# {
# "server": {
# "host": "0.0.0.0", # Required for Docker container accessibility
# "port": 8503,
# "startup_eosdash": true,
# "eosdash_host": "0.0.0.0", # Required for Docker container accessibility
# "eosdash_port": 8504
# }
# }
#
# Example volume mounts (uncomment to use):
# volumes:
# - ./config:/opt/eos/config # Mount local config directory
# - ./cache:/opt/eos/cache # Mount local cache directory
# - ./output:/opt/eos/output # Mount local output directory

File diff suppressed because it is too large Load Diff

View File

@@ -63,7 +63,7 @@ Args:
year_energy (float): Yearly energy consumption in Wh. year_energy (float): Yearly energy consumption in Wh.
Note: Note:
Set LoadAkkudoktor as load_provider, then update data with Set LoadAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=load_mean' instead. '/v1/prediction/list?key=load_mean' instead.
@@ -91,6 +91,8 @@ Fastapi Optimize
- `start_hour` (query, optional): Defaults to current hour of the day. - `start_hour` (query, optional): Defaults to current hour of the day.
- `ngen` (query, optional): No description provided.
**Request Body**: **Request Body**:
- `application/json`: { - `application/json`: {
@@ -121,7 +123,7 @@ If no forecast values are available the missing ones at the start of the series
filled with the first available forecast value. filled with the first available forecast value.
Note: Note:
Set PVForecastAkkudoktor as pvforecast_provider, then update data with Set PVForecastAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=pvforecast_ac_power' and '/v1/prediction/list?key=pvforecast_ac_power' and
@@ -151,7 +153,7 @@ Note:
Electricity price charges are added. Electricity price charges are added.
Note: Note:
Set ElecPriceAkkudoktor as elecprice_provider, then update data with Set ElecPriceAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=elecprice_marketprice_wh' or '/v1/prediction/list?key=elecprice_marketprice_wh' or
@@ -164,6 +166,127 @@ Note:
--- ---
## GET /v1/admin/cache
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_get_v1_admin_cache_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_get_v1_admin_cache_get)
Fastapi Admin Cache Get
```
Current cache management data.
Returns:
data (dict): The management data.
```
**Responses**:
- **200**: Successful Response
---
## POST /v1/admin/cache/clear
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_clear_post_v1_admin_cache_clear_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_clear_post_v1_admin_cache_clear_post)
Fastapi Admin Cache Clear Post
```
Clear the cache from expired data.
Deletes expired cache files.
Args:
clear_all (Optional[bool]): Delete all cached files. Default is False.
Returns:
data (dict): The management data after cleanup.
```
**Parameters**:
- `clear_all` (query, optional): No description provided.
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## POST /v1/admin/cache/load
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_load_post_v1_admin_cache_load_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_load_post_v1_admin_cache_load_post)
Fastapi Admin Cache Load Post
```
Load cache management data.
Returns:
data (dict): The management data that was loaded.
```
**Responses**:
- **200**: Successful Response
---
## POST /v1/admin/cache/save
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_save_post_v1_admin_cache_save_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_save_post_v1_admin_cache_save_post)
Fastapi Admin Cache Save Post
```
Save the current cache management data.
Returns:
data (dict): The management data that was saved.
```
**Responses**:
- **200**: Successful Response
---
## POST /v1/admin/server/restart
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_server_restart_post_v1_admin_server_restart_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_server_restart_post_v1_admin_server_restart_post)
Fastapi Admin Server Restart Post
```
Restart the server.
Restart EOS properly by starting a new instance before exiting the old one.
```
**Responses**:
- **200**: Successful Response
---
## POST /v1/admin/server/shutdown
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_server_shutdown_post_v1_admin_server_shutdown_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_server_shutdown_post_v1_admin_server_shutdown_post)
Fastapi Admin Server Shutdown Post
```
Shutdown the server.
```
**Responses**:
- **200**: Successful Response
---
## GET /v1/config ## GET /v1/config
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_v1_config_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_v1_config_get) **Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_v1_config_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_v1_config_get)
@@ -190,11 +313,11 @@ Returns:
Fastapi Config Put Fastapi Config Put
``` ```
Write the provided settings into the current settings. Update the current config with the provided settings.
The existing settings are completely overwritten. Note that for any setting Note that for any setting value that is None or unset, the configuration will fall back to
value that is None, the configuration will fall back to values from other sources such as values from other sources such as environment variables, the EOS configuration file, or default
environment variables, the EOS configuration file, or default values. values.
Args: Args:
settings (SettingsEOS): The settings to write into the current settings. settings (SettingsEOS): The settings to write into the current settings.
@@ -203,311 +326,11 @@ Returns:
configuration (ConfigEOS): The current configuration after the write. configuration (ConfigEOS): The current configuration after the write.
``` ```
**Parameters**: **Request Body**:
- `server_eos_host` (query, optional): EOS server IP address. - `application/json`: {
"$ref": "#/components/schemas/SettingsEOS"
- `server_eos_port` (query, optional): EOS server IP port number. }
- `server_eos_verbose` (query, optional): Enable debug output
- `server_eos_startup_eosdash` (query, optional): EOS server to start EOSdash server.
- `server_eosdash_host` (query, optional): EOSdash server IP address.
- `server_eosdash_port` (query, optional): EOSdash server IP port number.
- `weatherimport_file_path` (query, optional): Path to the file to import weather data from.
- `weatherimport_json` (query, optional): JSON string, dictionary of weather forecast value lists.
- `weather_provider` (query, optional): Weather provider id of provider to be used.
- `pvforecastimport_file_path` (query, optional): Path to the file to import PV forecast data from.
- `pvforecastimport_json` (query, optional): JSON string, dictionary of PV forecast value lists.
- `pvforecast_provider` (query, optional): PVForecast provider id of provider to be used.
- `pvforecast0_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast0_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast0_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast0_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast0_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast0_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast0_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast0_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast0_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast0_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast0_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast0_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast0_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast0_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast0_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast0_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast1_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast1_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast1_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast1_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast1_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast1_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast1_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast1_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast1_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast1_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast1_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast1_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast1_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast1_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast1_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast1_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast2_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast2_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast2_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast2_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast2_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast2_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast2_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast2_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast2_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast2_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast2_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast2_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast2_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast2_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast2_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast2_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast3_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast3_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast3_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast3_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast3_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast3_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast3_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast3_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast3_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast3_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast3_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast3_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast3_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast3_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast3_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast3_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast4_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast4_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast4_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast4_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast4_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast4_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast4_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast4_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast4_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast4_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast4_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast4_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast4_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast4_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast4_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast4_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast5_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast5_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast5_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast5_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast5_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast5_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast5_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast5_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast5_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast5_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast5_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast5_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast5_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast5_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast5_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast5_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `load_import_file_path` (query, optional): Path to the file to import load data from.
- `load_import_json` (query, optional): JSON string, dictionary of load forecast value lists.
- `loadakkudoktor_year_energy` (query, optional): Yearly energy consumption (kWh).
- `load_provider` (query, optional): Load provider id of provider to be used.
- `elecpriceimport_file_path` (query, optional): Path to the file to import elecprice data from.
- `elecpriceimport_json` (query, optional): JSON string, dictionary of electricity price forecast value lists.
- `elecprice_provider` (query, optional): Electricity price provider id of provider to be used.
- `elecprice_charges_kwh` (query, optional): Electricity price charges (€/kWh).
- `prediction_hours` (query, optional): Number of hours into the future for predictions
- `prediction_historic_hours` (query, optional): Number of hours into the past for historical predictions data
- `latitude` (query, optional): Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)
- `longitude` (query, optional): Longitude in decimal degrees, within -180 to 180 (°)
- `optimization_hours` (query, optional): Number of hours into the future for optimizations.
- `optimization_penalty` (query, optional): Penalty factor used in optimization.
- `optimization_ev_available_charge_rates_percent` (query, optional): Charge rates available for the EV in percent of maximum charge.
- `measurement_load0_name` (query, optional): Name of the load0 source (e.g. 'Household', 'Heat Pump')
- `measurement_load1_name` (query, optional): Name of the load1 source (e.g. 'Household', 'Heat Pump')
- `measurement_load2_name` (query, optional): Name of the load2 source (e.g. 'Household', 'Heat Pump')
- `measurement_load3_name` (query, optional): Name of the load3 source (e.g. 'Household', 'Heat Pump')
- `measurement_load4_name` (query, optional): Name of the load4 source (e.g. 'Household', 'Heat Pump')
- `battery_provider` (query, optional): Id of Battery simulation provider.
- `battery_capacity` (query, optional): Battery capacity [Wh].
- `battery_initial_soc` (query, optional): Battery initial state of charge [%].
- `battery_soc_min` (query, optional): Battery minimum state of charge [%].
- `battery_soc_max` (query, optional): Battery maximum state of charge [%].
- `battery_charging_efficiency` (query, optional): Battery charging efficiency [%].
- `battery_discharging_efficiency` (query, optional): Battery discharging efficiency [%].
- `battery_max_charging_power` (query, optional): Battery maximum charge power [W].
- `bev_provider` (query, optional): Id of Battery Electric Vehicle simulation provider.
- `bev_capacity` (query, optional): Battery Electric Vehicle capacity [Wh].
- `bev_initial_soc` (query, optional): Battery Electric Vehicle initial state of charge [%].
- `bev_soc_max` (query, optional): Battery Electric Vehicle maximum state of charge [%].
- `bev_charging_efficiency` (query, optional): Battery Electric Vehicle charging efficiency [%].
- `bev_discharging_efficiency` (query, optional): Battery Electric Vehicle discharging efficiency [%].
- `bev_max_charging_power` (query, optional): Battery Electric Vehicle maximum charge power [W].
- `dishwasher_provider` (query, optional): Id of Dish Washer simulation provider.
- `dishwasher_consumption` (query, optional): Dish Washer energy consumption [Wh].
- `dishwasher_duration` (query, optional): Dish Washer usage duration [h].
- `inverter_provider` (query, optional): Id of PV Inverter simulation provider.
- `inverter_power_max` (query, optional): Inverter maximum power [W].
- `logging_level_default` (query, optional): EOS default logging level.
- `data_folder_path` (query, optional): Path to EOS data directory.
- `data_output_subpath` (query, optional): Sub-path for the EOS output data directory.
- `data_cache_subpath` (query, optional): Sub-path for the EOS cache data directory.
**Responses**: **Responses**:
@@ -517,25 +340,6 @@ Returns:
--- ---
## GET /v1/config/file
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_file_get_v1_config_file_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_file_get_v1_config_file_get)
Fastapi Config File Get
```
Get the settings as defined by the EOS configuration file.
Returns:
settings (SettingsEOS): The settings defined by the EOS configuration file.
```
**Responses**:
- **200**: Successful Response
---
## PUT /v1/config/file ## PUT /v1/config/file
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_file_put_v1_config_file_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_file_put_v1_config_file_put) **Links**: [local](http://localhost:8503/docs#/default/fastapi_config_file_put_v1_config_file_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_file_put_v1_config_file_put)
@@ -555,14 +359,14 @@ Returns:
--- ---
## POST /v1/config/update ## POST /v1/config/reset
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_update_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_update_post_v1_config_update_post) **Links**: [local](http://localhost:8503/docs#/default/fastapi_config_reset_post_v1_config_reset_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_reset_post_v1_config_reset_post)
Fastapi Config Update Post Fastapi Config Reset Post
``` ```
Update the configuration from the EOS configuration file. Reset the configuration to the EOS configuration file.
Returns: Returns:
configuration (ConfigEOS): The current configuration after update. configuration (ConfigEOS): The current configuration after update.
@@ -574,28 +378,132 @@ Returns:
--- ---
## PUT /v1/config/value ## GET /v1/config/{path}
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_value_put_v1_config_value_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_value_put_v1_config_value_put) **Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_key_v1_config__path__get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_key_v1_config__path__get)
Fastapi Config Value Put Fastapi Config Get Key
``` ```
Set the configuration option in the settings. Get the value of a nested key or index in the config model.
Args: Args:
key (str): configuration key path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
value (Any): configuration value
Returns: Returns:
configuration (ConfigEOS): The current configuration after the write. value (Any): The value of the selected nested key.
``` ```
**Parameters**: **Parameters**:
- `key` (query, required): configuration key - `path` (path, required): The nested path to the configuration key (e.g., general/latitude).
- `value` (query, required): configuration value **Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## PUT /v1/config/{path}
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_put_key_v1_config__path__put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_put_key_v1_config__path__put)
Fastapi Config Put Key
```
Update a nested key or index in the config model.
Args:
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
value (Any): The new value to assign to the key or index at path.
Returns:
configuration (ConfigEOS): The current configuration after the update.
```
**Parameters**:
- `path` (path, required): The nested path to the configuration key (e.g., general/latitude).
**Request Body**:
- `application/json`: {
"anyOf": [
{},
{
"type": "null"
}
],
"description": "The value to assign to the specified configuration path (can be None).",
"title": "Value"
}
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## GET /v1/health
**Links**: [local](http://localhost:8503/docs#/default/fastapi_health_get_v1_health_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_health_get_v1_health_get)
Fastapi Health Get
```
Health check endpoint to verify that the EOS server is alive.
```
**Responses**:
- **200**: Successful Response
---
## GET /v1/logging/log
**Links**: [local](http://localhost:8503/docs#/default/fastapi_logging_get_log_v1_logging_log_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_logging_get_log_v1_logging_log_get)
Fastapi Logging Get Log
```
Get structured log entries from the EOS log file.
Filters and returns log entries based on the specified query parameters. The log
file is expected to contain newline-delimited JSON entries.
Args:
limit (int): Maximum number of entries to return.
level (Optional[str]): Filter logs by severity level (e.g., DEBUG, INFO).
contains (Optional[str]): Return only logs that include this string in the message.
regex (Optional[str]): Return logs that match this regular expression in the message.
from_time (Optional[str]): ISO 8601 timestamp to filter logs not older than this.
to_time (Optional[str]): ISO 8601 timestamp to filter logs not newer than this.
tail (bool): If True, fetch the most recent log entries (like `tail`).
Returns:
JSONResponse: A JSON list of log entries.
```
**Parameters**:
- `limit` (query, optional): Maximum number of log entries to return.
- `level` (query, optional): Filter by log level (e.g., INFO, ERROR).
- `contains` (query, optional): Filter logs containing this substring.
- `regex` (query, optional): Filter logs by matching regex in message.
- `from_time` (query, optional): Start time (ISO format) for filtering logs.
- `to_time` (query, optional): End time (ISO format) for filtering logs.
- `tail` (query, optional): If True, returns the most recent lines (tail mode).
**Responses**: **Responses**:
@@ -821,6 +729,93 @@ Merge the measurement of given key and value into EOS measurements at given date
--- ---
## GET /v1/prediction/dataframe
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_dataframe_get_v1_prediction_dataframe_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_dataframe_get_v1_prediction_dataframe_get)
Fastapi Prediction Dataframe Get
```
Get prediction for given key within given date range as series.
Args:
key (str): Prediction key
start_datetime (Optional[str]): Starting datetime (inclusive).
Defaults to start datetime of latest prediction.
end_datetime (Optional[str]: Ending datetime (exclusive).
Defaults to end datetime of latest prediction.
```
**Parameters**:
- `keys` (query, required): Prediction keys.
- `start_datetime` (query, optional): Starting datetime (inclusive).
- `end_datetime` (query, optional): Ending datetime (exclusive).
- `interval` (query, optional): Time duration for each interval. Defaults to 1 hour.
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## PUT /v1/prediction/import/{provider_id}
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_import_provider_v1_prediction_import__provider_id__put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_import_provider_v1_prediction_import__provider_id__put)
Fastapi Prediction Import Provider
```
Import prediction for given provider ID.
Args:
provider_id: ID of provider to update.
data: Prediction data.
force_enable: Update data even if provider is disabled.
Defaults to False.
```
**Parameters**:
- `provider_id` (path, required): Provider ID.
- `force_enable` (query, optional): No description provided.
**Request Body**:
- `application/json`: {
"anyOf": [
{
"$ref": "#/components/schemas/PydanticDateTimeDataFrame"
},
{
"$ref": "#/components/schemas/PydanticDateTimeData"
},
{
"type": "object",
"additionalProperties": true
},
{
"type": "null"
}
],
"title": "Data"
}
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## GET /v1/prediction/keys ## GET /v1/prediction/keys
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_keys_get_v1_prediction_keys_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_keys_get_v1_prediction_keys_get) **Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_keys_get_v1_prediction_keys_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_keys_get_v1_prediction_keys_get)
@@ -864,7 +859,32 @@ Args:
- `end_datetime` (query, optional): Ending datetime (exclusive). - `end_datetime` (query, optional): Ending datetime (exclusive).
- `interval` (query, optional): Time duration for each interval. - `interval` (query, optional): Time duration for each interval. Defaults to 1 hour.
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## GET /v1/prediction/providers
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_providers_get_v1_prediction_providers_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_providers_get_v1_prediction_providers_get)
Fastapi Prediction Providers Get
```
Get a list of available prediction providers.
Args:
enabled (bool): Return enabled/disabled providers. If unset, return all providers.
```
**Parameters**:
- `enabled` (query, optional): No description provided.
**Responses**: **Responses**:

3
docs/_static/eos.css vendored Normal file
View File

@@ -0,0 +1,3 @@
.wy-nav-content {
max-width: 90% !important;
}

View File

@@ -28,7 +28,7 @@ management.
Energy management is the overall process to provide planning data for scheduling the different Energy management is the overall process to provide planning data for scheduling the different
devices in your system in an optimal way. Energy management cares for the update of predictions and devices in your system in an optimal way. Energy management cares for the update of predictions and
the optimization of the planning based on the simulated behavior of the devices. The planning is on the optimization of the planning based on the simulated behavior of the devices. The planning is on
the hour. Sub-hour energy management is left the hour.
### Optimization ### Optimization

View File

@@ -7,10 +7,9 @@ management.
## Storing Configuration ## Storing Configuration
EOS stores configuration data in a **key-value store**, where a `configuration key` refers to the EOS stores configuration data in a `nested structure`. Note that configuration changes inside EOS
unique identifier used to store and retrieve specific configuration data. Note that the key-value are updated in memory, meaning all changes will be lost upon restarting the EOS REST server if not
store is memory-based, meaning all stored data will be lost upon restarting the EOS REST server if saved to the `EOS configuration file`.
not saved to the `EOS configuration file`.
Some `configuration keys` are read-only and cannot be altered. These keys are either set up by other Some `configuration keys` are read-only and cannot be altered. These keys are either set up by other
means, such as environment variables, or determined from other information. means, such as environment variables, or determined from other information.
@@ -25,7 +24,8 @@ Use endpoint `PUT /v1/config/file` to save the current configuration to the
### Load Configuration File ### Load Configuration File
Use endpoint `POST /v1/config/update` to update the configuration from the `EOS configuration file`. Use endpoint `POST /v1/config/reset` to reset the configuration to the values in the
`EOS configuration file`.
## Configuration Sources and Priorities ## Configuration Sources and Priorities
@@ -36,26 +36,25 @@ The configuration sources and their priorities are as follows:
3. `EOS Configuration File`: Read at startup of the REST server and on request 3. `EOS Configuration File`: Read at startup of the REST server and on request
4. `Default Values` 4. `Default Values`
### Settings ### Runtime Config Updates
Settings are sets of configuration data that take precedence over all other configuration data from The EOS configuration can be updated at runtime. Note that those updates are not persistent
different sources. Note that settings are not persistent. To make the current configuration with the automatically. However it is possible to save the configuration to the `EOS configuration file`.
current settings persistent, save the configuration to the `EOS configuration file`.
Use the following endpoints to change the current configuration settings: Use the following endpoints to change the current runtime configuration:
- `PUT /v1/config`: Replaces the entire configuration settings. - `PUT /v1/config`: Update the entire or parts of the configuration.
- `PUT /v1/config/value`: Sets a specific configuration option.
### Environment Variables ### Environment Variables
All `configuration keys` can be set by environment variables with the same name. EOS recognizes the All `configuration keys` can be set by environment variables prefixed with `EOS_` and separated by
following special environment variables: `__` for nested structures. Environment variables are case insensitive.
EOS recognizes the following special environment variables (case sensitive):
- `EOS_CONFIG_DIR`: The directory to search for an EOS configuration file. - `EOS_CONFIG_DIR`: The directory to search for an EOS configuration file.
- `EOS_DIR`: The directory used by EOS for data, which will also be searched for an EOS - `EOS_DIR`: The directory used by EOS for data, which will also be searched for an EOS
configuration file. configuration file.
- `EOS_LOGGING_LEVEL`: The logging level to use in EOS.
### EOS Configuration File ### EOS Configuration File
@@ -66,7 +65,7 @@ If you do not have a configuration file, it will be automatically created on the
the REST server in a system-dependent location. the REST server in a system-dependent location.
To determine the location of the configuration file used by EOS, ask the REST server. The endpoint To determine the location of the configuration file used by EOS, ask the REST server. The endpoint
`GET /v1/config` provides the `config_file_path` configuration key. `GET /v1/config` provides the `general.config_file_path` configuration key.
EOS searches for the configuration file in the following order: EOS searches for the configuration file in the following order:
@@ -75,9 +74,15 @@ EOS searches for the configuration file in the following order:
3. A platform-specific default directory for EOS 3. A platform-specific default directory for EOS
4. The current working directory 4. The current working directory
The first available configuration file found in these directories is loaded. If no configuration The first configuration file available in these directories is loaded. If no configuration file is
file is found, a default configuration file is created in the platform-specific default directory, found, a default configuration file is created, and the default settings are written to it. The
and default settings are loaded into it. location of the created configuration file follows the same order in which EOS searches for
configuration files, and it depends on whether the relevant environment variables are set.
Use the following endpoints to interact with the configuration file:
- `PUT /v1/config/file`: Save the current configuration to the configuration file.
- `PUT /v1/config/reset`: Reload the configuration file, all unsaved runtime configuration is reset.
### Default Values ### Default Values

View File

@@ -20,8 +20,8 @@ Andreas Schmitz uses [Node-RED](https://nodered.org/) as part of his home automa
### Node-Red Resources ### Node-Red Resources
- [Installation Guide (German)](https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/) - [Installation Guide (German)](https://www.youtube.com/playlist?list=PL8_vk9A-s7zLD865Oou6y3EeQLlNtu-Hn)
\— A detailed guide on integrating an early version of EOS with `Node-RED`. \— A detailed guide on integrating EOS with `Node-RED`.
## Home Assistant ## Home Assistant
@@ -34,3 +34,8 @@ emphasizes local control and user privacy.
- Duetting's [EOS Home Assistant Addon](https://github.com/Duetting/ha_eos_addon) — Additional - Duetting's [EOS Home Assistant Addon](https://github.com/Duetting/ha_eos_addon) — Additional
details can be found in this [discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294). details can be found in this [discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).
## EOS Connect
[EOS connect](https://github.com/ohAnd/EOS_connect) uses `EOS` for energy management and optimization,
and connects to smart home platforms to monitor, forecast, and control energy flows.

View File

@@ -139,7 +139,7 @@ of components.
![Integration](../_static/introduction/integration.png) ![Integration](../_static/introduction/integration.png)
However, the components are not integrated by the EOS itself, but must be intergrated by However, the components are not integrated by the EOS itself, but must be integrated by
the user using an integration solution and currently requires some effort and technical the user using an integration solution and currently requires some effort and technical
know-how. know-how.
@@ -153,7 +153,7 @@ Node-RED offers a large number of types of nodes that allow access via the proto
commonly used in this area, such as Modbus or MQTT. Access to any existing databases, commonly used in this area, such as Modbus or MQTT. Access to any existing databases,
such as InfluxDB or PostgreSQL, is also possible via nodes provided by Node-RED. such as InfluxDB or PostgreSQL, is also possible via nodes provided by Node-RED.
It becomes easier if a smart home solution like Homa Assistant, openHAB or ioBroker or It becomes easier if a smart home solution like Home Assistant, openHAB or ioBroker or
solutions such as evcc or openWB are already in use. In this case, these smart home solutions such as evcc or openWB are already in use. In this case, these smart home
solutions already take over the technical integration and communication with the components solutions already take over the technical integration and communication with the components
at a technical level and Node-RED offers nodes for accessing these solutions, so that the at a technical level and Node-RED offers nodes for accessing these solutions, so that the
@@ -161,7 +161,7 @@ corresponding sources can be easily integrated into a flow.
In Home Assistant you could use an automation to prepare the input payload for EOS and In Home Assistant you could use an automation to prepare the input payload for EOS and
then use the RESTful integration to call EOS. Based on this concept there is already a then use the RESTful integration to call EOS. Based on this concept there is already a
home assistand add-on created by [Duetting](#duetting-solution). Home Assistant add-on created by [Duetting](#duetting-solution).
The plan created by EOS must also be executed via the chosen integration solution, The plan created by EOS must also be executed via the chosen integration solution,
with the respective devices receiving their instructions according to the plan. with the respective devices receiving their instructions according to the plan.
@@ -174,7 +174,7 @@ but usually find good local optima very quickly in a large solution space.
## Links ## Links
- [German Video explaining the basic concept and installation process for the early version of EOS (YouTube)](https://www.youtube.com/live/ftQULW4-1ts?si=oDdBBifCpUmiCXaY) - [German Videos explaining the basic concept and installation process of EOS (YouTube)](https://www.youtube.com/playlist?list=PL8_vk9A-s7zLD865Oou6y3EeQLlNtu-Hn)
- [German Forum of Akkudoktor EOS](https://akkudoktor.net/c/der-akkudoktor/eos) - [German Forum of Akkudoktor EOS](https://akkudoktor.net/c/der-akkudoktor/eos)
- [Akkudoktor-EOS GitHub Repository](https://github.com/Akkudoktor-EOS/EOS) - [Akkudoktor-EOS GitHub Repository](https://github.com/Akkudoktor-EOS/EOS)
- [Latest EOS Documentation](https://akkudoktor-eos.readthedocs.io/en/latest/) - [Latest EOS Documentation](https://akkudoktor-eos.readthedocs.io/en/latest/)

View File

@@ -0,0 +1,75 @@
% SPDX-License-Identifier: Apache-2.0
(logging-page)=
# Logging
EOS automatically records important events and messages to help you understand whats happening and
to troubleshoot problems.
## How Logging Works
- By default, logs are shown in your terminal (console).
- You can also save logs to a file for later review.
- Log files are rotated automatically to avoid becoming too large.
## Controlling Log Details
### 1. Command-Line Option
Set the amount of log detail shown on the console by using `--log-level` when starting EOS.
Example:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
.venv\Scripts\python src/akkudoktoreos/server/eos.py --log-level DEBUG
.. tab:: Linux
.. code-block:: bash
.venv/bin/python src/akkudoktoreos/server/eos.py --log-level DEBUG
```
Common levels:
- DEBUG (most detail)
- INFO (default)
- WARNING
- ERROR
- CRITICAL (least detail)
### 2. Configuration File
You can also set logging options in your EOS configuration file (EOS.config.json).
```Json
{
"logging": {
"console_level": "INFO",
"file_level": "DEBUG"
}
}
```
### 3. Environment Variable
You can also control the log level by setting the `EOS_LOGGING__CONSOLE_LEVEL` and the
`EOS_LOGGING__FILE_LEVEL` environment variables.
```bash
EOS_LOGGING__CONSOLE_LEVEL="INFO"
EOS_LOGGING__FILE_LEVEL="DEBUG"
```
## File Logging
If the `file_level` configuration is set, log records are written to a rotating log file. The log
file is in the data output directory and named `eos.log`. You may directly read the file or use
the `/v1/logging/log` endpoint to access the file log.

View File

@@ -56,21 +56,21 @@ A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html)
The EOS measurement store provides for storing meter readings of loads. There are currently five loads The EOS measurement store provides for storing meter readings of loads. There are currently five loads
foreseen. The associated `measurement key`s are: foreseen. The associated `measurement key`s are:
- `measurement_load0_mr`: Load0 meter reading [kWh] - `load0_mr`: Load0 meter reading [kWh]
- `measurement_load1_mr`: Load1 meter reading [kWh] - `load1_mr`: Load1 meter reading [kWh]
- `measurement_load2_mr`: Load2 meter reading [kWh] - `load2_mr`: Load2 meter reading [kWh]
- `measurement_load3_mr`: Load3 meter reading [kWh] - `load3_mr`: Load3 meter reading [kWh]
- `measurement_load4_mr`: Load4 meter reading [kWh] - `load4_mr`: Load4 meter reading [kWh]
For ease of use, you can assign descriptive names to the `measurement key`s to represent your For ease of use, you can assign descriptive names to the `measurement key`s to represent your
system's load sources. Use the following `configuration options` to set these names system's load sources. Use the following `configuration options` to set these names
(e.g., 'Dish Washer', 'Heat Pump'): (e.g., 'Dish Washer', 'Heat Pump'):
- `measurement_load0_name`: Name of the load0 source - `load0_name`: Name of the load0 source
- `measurement_load1_name`: Name of the load1 source - `load1_name`: Name of the load1 source
- `measurement_load2_name`: Name of the load2 source - `load2_name`: Name of the load2 source
- `measurement_load3_name`: Name of the load3 source - `load3_name`: Name of the load3 source
- `measurement_load4_name`: Name of the load4 source - `load4_name`: Name of the load4 source
Load measurements can be stored for any datetime. The values between different meter readings are Load measurements can be stored for any datetime. The values between different meter readings are
linearly approximated. Since optimization occurs on the hour, storing values between hours is linearly approximated. Since optimization occurs on the hour, storing values between hours is
@@ -84,8 +84,8 @@ for specified intervals, usually one hour. This aggregated data can be used for
The EOS measurement store also allows for the storage of meter readings for grid import and export. The EOS measurement store also allows for the storage of meter readings for grid import and export.
The associated `measurement key`s are: The associated `measurement key`s are:
- `measurement_grid_export_mr`: Export to grid meter reading [kWh] - `grid_export_mr`: Export to grid meter reading [kWh]
- `measurement_grid_import_mr`: Import from grid meter reading [kWh] - `grid_import_mr`: Import from grid meter reading [kWh]
:::{admonition} Todo :::{admonition} Todo
:class: note :class: note

View File

@@ -21,6 +21,7 @@ including electricity prices, battery storage capacity, PV forecast, and tempera
"strompreis_euro_pro_wh": [0.0003784, 0.0003868, ..., 0.00034102, 0.00033709] "strompreis_euro_pro_wh": [0.0003784, 0.0003868, ..., 0.00034102, 0.00033709]
}, },
"pv_akku": { "pv_akku": {
"device_id": "battery1",
"capacity_wh": 12000, "capacity_wh": 12000,
"charging_efficiency": 0.92, "charging_efficiency": 0.92,
"discharging_efficiency": 0.92, "discharging_efficiency": 0.92,
@@ -30,9 +31,12 @@ including electricity prices, battery storage capacity, PV forecast, and tempera
"max_soc_percentage": 100 "max_soc_percentage": 100
}, },
"inverter": { "inverter": {
"device_id": "inverter1",
"max_power_wh": 15500 "max_power_wh": 15500
"battery_id": "battery1",
}, },
"eauto": { "eauto": {
"device_id": "auto1",
"capacity_wh": 64000, "capacity_wh": 64000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
@@ -95,6 +99,7 @@ Verify prices against your local tariffs.
#### Configuration #### Configuration
- `device_id`: ID of battery
- `capacity_wh`: Total battery capacity in Wh - `capacity_wh`: Total battery capacity in Wh
- `charging_efficiency`: Charging efficiency (0-1) - `charging_efficiency`: Charging efficiency (0-1)
- `discharging_efficiency`: Discharging efficiency (0-1) - `discharging_efficiency`: Discharging efficiency (0-1)
@@ -108,10 +113,13 @@ Verify prices against your local tariffs.
### Inverter ### Inverter
- `device_id`: ID of inverter
- `max_power_wh`: Maximum inverter power in Wh - `max_power_wh`: Maximum inverter power in Wh
- `battery_id`: ID of battery
### Electric Vehicle (EV) ### Electric Vehicle (EV)
- `device_id`: ID of electric vehicle
- `capacity_wh`: Battery capacity in Wh - `capacity_wh`: Battery capacity in Wh
- `charging_efficiency`: Charging efficiency (0-1) - `charging_efficiency`: Charging efficiency (0-1)
- `discharging_efficiency`: Discharging efficiency (0-1) - `discharging_efficiency`: Discharging efficiency (0-1)

View File

@@ -20,10 +20,14 @@ data is lost on re-start of the EOS REST server.
## Prediction Providers ## Prediction Providers
Most predictions can be sourced from various providers. The specific provider to use is configured Most predictions can be sourced from various providers. The specific provider to use is configured
in the EOS configuration. For example: in the EOS configuration and can be set by prediction type. For example:
```python ```python
weather_provider = "ClearOutside" {
"weather": {
"provider": "ClearOutside"
}
}
``` ```
Some providers offer multiple prediction keys. For instance, a weather provider might provide data Some providers offer multiple prediction keys. For instance, a weather provider might provide data
@@ -74,7 +78,7 @@ predictions are adjusted by real data from your system's measurements if given t
For example, the load prediction provider `LoadAkkudoktor` takes generic load data assembled by For example, the load prediction provider `LoadAkkudoktor` takes generic load data assembled by
Akkudoktor.net, maps that to the yearly energy consumption given in the configuration option Akkudoktor.net, maps that to the yearly energy consumption given in the configuration option
`loadakkudoktor_year_energy`, and finally adjusts the predicted load by the `measurement_loads` `loadakkudoktor_year_energy`, and finally adjusts the predicted load by the `loads`
of your system. of your system.
## Prediction Updates ## Prediction Updates
@@ -110,23 +114,45 @@ Prediction keys:
Configuration options: Configuration options:
- `elecprice_provider`: Electricity price provider id of provider to be used. - `elecprice`: Electricity price configuration.
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net. - `provider`: Electricity price provider id of provider to be used.
- `ElecPriceImport`: Imports from a file or JSON string.
- `elecprice_charges_kwh`: Electricity price charges (€/kWh). - `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
- `elecpriceimport_file_path`: Path to the file to import electricity price forecast data from. - `ElecPriceEnergyCharts`: Retrieves from Energy-Charts.info.
- `elecpriceimport_json`: JSON string, dictionary of electricity price forecast value lists. - `ElecPriceImport`: Imports from a file or JSON string.
- `charges_kwh`: Electricity price charges (€/kWh).
- `vat_rate`: VAT rate factor applied to electricity price when charges are used (default: 1.19).
- `provider_settings.import_file_path`: Path to the file to import electricity price forecast data from.
- `provider_settings.import_json`: JSON string, dictionary of electricity price forecast value lists.
### ElecPriceAkkudoktor Provider ### ElecPriceAkkudoktor Provider
The `ElecPriceAkkudoktor` provider retrieves electricity prices directly from **Akkudoktor.net**, The `ElecPriceAkkudoktor` provider retrieves electricity prices directly from **Akkudoktor.net**,
which supplies price data for the next 24 hours. For periods beyond 24 hours, the provider generates which supplies price data for the next 24 hours. For periods beyond 24 hours, the provider generates
prices by extrapolating historical price data combined with the most recent actual prices obtained prices by extrapolating historical price data combined with the most recent actual prices obtained
from Akkudoktor.net. Electricity price charges given in the `elecprice_charges_kwh` configuration from Akkudoktor.net. Electricity price charges given in the `charges_kwh` configuration
option are added. option are added.
### ElecPriceEnergyCharts Provider
The `ElecPriceEnergyCharts` provider retrieves day-ahead electricity market prices from
[Energy-Charts.info](https://www.Energy-Charts.info). It supports both short-term and extended forecasting by combining
real-time market data with historical price trends.
- For the next 24 hours, market prices are fetched directly from Energy-Charts.info.
- For periods beyond 24 hours, prices are estimated using extrapolation based on historical data and the latest
available market values.
Charges and VAT
- If `charges_kwh` configuration option is greater than 0, the electricity price is calculated as:
`(market price + charges_kwh) * vat_rate` where `vat_rate` is configurable (default: 1.19 for 19% VAT).
- If `charges_kwh` is set to 0, the electricity price is simply: `market_price` (no VAT applied).
**Note:** For the most accurate forecasts, it is recommended to set the `historic_hours` parameter to 840.
### ElecPriceImport Provider ### ElecPriceImport Provider
The `ElecPriceImport` provider is designed to import electricity prices from a file or a JSON The `ElecPriceImport` provider is designed to import electricity prices from a file or a JSON
@@ -138,8 +164,11 @@ The prediction key for the electricity price forecast data is:
- `elecprice_marketprice_wh`: Electricity market price per Wh (€/Wh). - `elecprice_marketprice_wh`: Electricity market price per Wh (€/Wh).
The electricity proce forecast data must be provided in one of the formats described in The electricity proce forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source must be given in the <project:#prediction-import-providers>. The data source can be given in the
`elecpriceimport_file_path` or `elecpriceimport_json` configuration option. `import_file_path` or `import_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/ElecPriceImport` endpoint.
## Load Prediction ## Load Prediction
@@ -151,14 +180,17 @@ Prediction keys:
Configuration options: Configuration options:
- `load_provider`: Load provider id of provider to be used. - `load`: Load configuration.
- `LoadAkkudoktor`: Retrieves from local database. - `provider`: Load provider id of provider to be used.
- `LoadImport`: Imports from a file or JSON string.
- `loadakkudoktor_year_energy`: Yearly energy consumption (kWh). - `LoadAkkudoktor`: Retrieves from local database.
- `loadimport_file_path`: Path to the file to import load forecast data from. - `LoadVrm`: Retrieves data from the VRM API by Victron Energy.
- `loadimport_json`: JSON string, dictionary of load forecast value lists. - `LoadImport`: Imports from a file or JSON string.
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
- `provider_settings.loadimport_file_path`: Path to the file to import load forecast data from.
- `provider_settings.loadimport_json`: JSON string, dictionary of load forecast value lists.
### LoadAkkudoktor Provider ### LoadAkkudoktor Provider
@@ -166,6 +198,27 @@ The `LoadAkkudoktor` provider retrieves generic load data from a local database
align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration
option. option.
### LoadVrm Provider
The `LoadVrm` provider retrieves load forecast data from the VRM API by Victron Energy.
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
```python
{
"load": {
"provider": "LoadVrm",
"provider_settings": {
"load_vrm_token": "dummy-token",
"load_vrm_idsite": 12345
}
```
The prediction keys for the load forecast data are:
- `load_mean`: Predicted load mean value (W).
### LoadImport Provider ### LoadImport Provider
The `LoadImport` provider is designed to import load forecast data from a file or a JSON The `LoadImport` provider is designed to import load forecast data from a file or a JSON
@@ -179,9 +232,12 @@ The prediction keys for the load forecast data are:
- `load_mean_adjusted`: Predicted load mean value adjusted by load measurement (W). - `load_mean_adjusted`: Predicted load mean value adjusted by load measurement (W).
The load forecast data must be provided in one of the formats described in The load forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source must be given in the `loadimport_file_path` <project:#prediction-import-providers>. The data source can be given in the `loadimport_file_path`
or `loadimport_json` configuration option. or `loadimport_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/LoadImport` endpoint.
## PV Power Prediction ## PV Power Prediction
Prediction keys: Prediction keys:
@@ -191,48 +247,52 @@ Prediction keys:
Configuration options: Configuration options:
- `pvforecast_provider`: PVForecast provider id of provider to be used. - `general`: General configuration.
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net. - `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
- `PVForecastImport`: Imports from a file or JSON string. - `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)" - `pvforecast`: PV forecast configuration.
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
- `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking. - `provider`: PVForecast provider id of provider to be used.
- `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
Clockwise from north (north=0, east=90, south=180, west=270). - `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
- `pvforecast<0..5>_userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - `PVForecastVrm`: Retrieves data from the VRM API by Victron Energy.
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW. - `PVForecastImport`: Imports from a file or JSON string.
- `pvforecast<0..5>_pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast<0..5>_mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing - `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
and 'building' for building-integrated. - `planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
- `pvforecast<0..5>_loss`: Sum of PV system losses in percent Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast<0..5>_trackingtype`: Type of suntracking. 0=fixed, - `planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
1=single horizontal axis aligned north-south, - `planes[].peakpower`: Nominal power of PV system in kW.
2=two-axis tracking, - `planes[].pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
3=vertical axis tracking, - `planes[].mountingplace`: Type of mounting for PV system.
4=single horizontal axis aligned east-west, Options are 'free' for free-standing and 'building' for building-integrated.
5=single inclined axis aligned north-south. - `planes[].loss`: Sum of PV system losses in percent
- `pvforecast<0..5>_optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking. - `planes[].trackingtype`: Type of suntracking.
- `pvforecast<0..5>_optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. 0=fixed,
- `pvforecast<0..5>_albedo`: Proportion of the light hitting the ground that it reflects back. 1=single horizontal axis aligned north-south,
- `pvforecast<0..5>_module_model`: Model of the PV modules of this plane. 2=two-axis tracking,
- `pvforecast<0..5>_inverter_model`: Model of the inverter of this plane. 3=vertical axis tracking,
- `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W] 4=single horizontal axis aligned east-west,
- `pvforecast<0..5>_modules_per_string`: Number of the PV modules of the strings of this plane. 5=single inclined axis aligned north-south.
- `pvforecast<0..5>_strings_per_inverter`: Number of the strings of the inverter of this plane. - `planes[].optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecastimport_file_path`: Path to the file to import PV forecast data from. - `planes[].optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecastimport_json`: JSON string, dictionary of PV forecast value lists. - `planes[].albedo`: Proportion of the light hitting the ground that it reflects back.
- `planes[].module_model`: Model of the PV modules of this plane.
- `planes[].inverter_model`: Model of the inverter of this plane.
- `planes[].inverter_paco`: AC power rating of the inverter. [W]
- `planes[].modules_per_string`: Number of the PV modules of the strings of this plane.
- `planes[].strings_per_inverter`: Number of the strings of the inverter of this plane.
- `provider_settings.import_file_path`: Path to the file to import PV forecast data from.
- `provider_settings.import_json`: JSON string, dictionary of PV forecast value lists.
--- ---
Some of the configuration options directly follow the Detailed definitions taken from
[PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en) [PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en).
nomenclature.
Detailed definitions taken from **PVGIS**: - `pvtechchoice`
- `pvforecast<0..5>_pvtechchoice`
The performance of PV modules depends on the temperature and on the solar irradiance, but the exact The performance of PV modules depends on the temperature and on the solar irradiance, but the exact
dependence varies between different types of PV modules. At the moment we can estimate the losses dependence varies between different types of PV modules. At the moment we can estimate the losses
@@ -251,7 +311,7 @@ variations of the spectrum of sunlight affects the overall energy production fro
the moment this calculation can be done for crystalline silicon and CdTe modules. Note that this the moment this calculation can be done for crystalline silicon and CdTe modules. Note that this
calculation is not yet available when using the NSRDB solar radiation database. calculation is not yet available when using the NSRDB solar radiation database.
- `pvforecast<0..5>_peakpower` - `peakpower`
This is the power that the manufacturer declares that the PV array can produce under standard test This is the power that the manufacturer declares that the PV array can produce under standard test
conditions (STC), which are a constant 1000W of solar irradiation per square meter in the plane of conditions (STC), which are a constant 1000W of solar irradiation per square meter in the plane of
@@ -267,7 +327,7 @@ value and the bifaciality factor, φ (if reported in the module data sheet) as:
P_BNPI = P_STC \* (1 + φ \* 0.135). NB this bifacial approach is not appropriate for BAPV or BIPV P_BNPI = P_STC \* (1 + φ \* 0.135). NB this bifacial approach is not appropriate for BAPV or BIPV
installations or for modules mounting on a N-S axis i.e. facing E-W. installations or for modules mounting on a N-S axis i.e. facing E-W.
- `pvforecast<0..5>_loss` - `loss`
The estimated system losses are all the losses in the system, which cause the power actually The estimated system losses are all the losses in the system, which cause the power actually
delivered to the electricity grid to be lower than the power produced by the PV modules. There are delivered to the electricity grid to be lower than the power produced by the PV modules. There are
@@ -279,7 +339,7 @@ in the first years.
We have given a default value of 14% for the overall losses. If you have a good idea that your value We have given a default value of 14% for the overall losses. If you have a good idea that your value
will be different (maybe due to a really high-efficiency inverter) you may reduce this value a little. will be different (maybe due to a really high-efficiency inverter) you may reduce this value a little.
- `pvforecast<0..5>_mountingplace` - `mountingplace`
For fixed (non-tracking) systems, the way the modules are mounted will have an influence on the For fixed (non-tracking) systems, the way the modules are mounted will have an influence on the
temperature of the module, which in turn affects the efficiency. Experiments have shown that if the temperature of the module, which in turn affects the efficiency. Experiments have shown that if the
@@ -295,7 +355,7 @@ Some types of mounting are in between these two extremes, for instance if the mo
a roof with curved roof tiles, allowing air to move behind the modules. In such cases, the a roof with curved roof tiles, allowing air to move behind the modules. In such cases, the
performance will be somewhere between the results of the two calculations that are possible here. performance will be somewhere between the results of the two calculations that are possible here.
- `pvforecast<0..5>_userhorizon` - `userhorizon`
Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. In the user horizon Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. In the user horizon
data each number represents the horizon height in degrees in a certain compass direction around the data each number represents the horizon height in degrees in a certain compass direction around the
@@ -312,11 +372,11 @@ Most of the configuration options are in line with the
Detailed definitions from **PVLib** for PVGIS data. Detailed definitions from **PVLib** for PVGIS data.
- `pvforecast<0..5>_surface_tilt`: - `surface_tilt`:
Tilt angle from horizontal plane. Tilt angle from horizontal plane.
- `pvforecast<0..5>_surface_azimuth` - `surface_azimuth`
Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180,
west=270). This is offset 180 degrees from the convention used by PVGIS. west=270). This is offset 180 degrees from the convention used by PVGIS.
@@ -328,51 +388,86 @@ west=270). This is offset 180 degrees from the convention used by PVGIS.
The `PVForecastAkkudoktor` provider retrieves the PV power forecast data directly from The `PVForecastAkkudoktor` provider retrieves the PV power forecast data directly from
**Akkudoktor.net**. **Akkudoktor.net**.
The following general configuration options of the PV system must be set: The following prediction configuration options of the PV system must be set:
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)" - `general.latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°) - `general.longitude`: Longitude in decimal degrees, within -180 to 180 (°)
For each plane `<0..5>` of the PV system the following configuration options must be set: For each plane of the PV system the following configuration options must be set:
- `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking. - `pvforecast.planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. - `pvforecast.planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
Clockwise from north (north=0, east=90, south=180, west=270). Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast<0..5>_userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - `pvforecast.planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W] - `pvforecast.planes[].inverter_paco`: AC power rating of the inverter. [W]
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW. - `pvforecast.planes[].peakpower`: Nominal power of PV system in kW.
Example: Example:
```Python ```Python
{ {
"latitude": 50.1234, "general": {
"longitude": 9.7654, "latitude": 50.1234,
"pvforecast_provider": "PVForecastAkkudoktor", "longitude": 9.7654,
"pvforecast0_peakpower": 5.0, },
"pvforecast0_surface_azimuth": -10, "pvforecast": {
"pvforecast0_surface_tilt": 7, "provider": "PVForecastAkkudoktor",
"pvforecast0_userhorizon": [20, 27, 22, 20], "planes": [
"pvforecast0_inverter_paco": 10000, {
"pvforecast1_peakpower": 4.8, "peakpower": 5.0,
"pvforecast1_surface_azimuth": -90, "surface_azimuth": -10,
"pvforecast1_surface_tilt": 7, "surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50], "userhorizon": [20, 27, 22, 20],
"pvforecast1_inverter_paco": 10000, "inverter_paco": 10000
"pvforecast2_peakpower": 1.4, },
"pvforecast2_surface_azimuth": -40, {
"pvforecast2_surface_tilt": 60, "peakpower": 4.8,
"pvforecast2_userhorizon": [60, 30, 0, 30], "surface_azimuth": -90,
"pvforecast2_inverter_paco": 2000, "surface_tilt": 7,
"pvforecast3_peakpower": 1.6, "userhorizon": [30, 30, 30, 50],
"pvforecast3_surface_azimuth": 5, "inverter_paco": 10000
"pvforecast3_surface_tilt": 45, },
"pvforecast3_userhorizon": [45, 25, 30, 60], {
"pvforecast3_inverter_paco": 1400, "peakpower": 1.4,
"pvforecast4_peakpower": None, "surface_azimuth": -40,
"surface_tilt": 60,
"userhorizon": [60, 30, 0, 30],
"inverter_paco": 2000
},
{
"peakpower": 1.6,
"surface_azimuth": 5,
"surface_tilt": 45,
"userhorizon": [45, 25, 30, 60],
"inverter_paco": 1400
}
]
}
} }
``` ```
### PVForecastVrm Provider
The `PVForecastVrm` provider retrieves pv power forecast data from the VRM API by Victron Energy.
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
```python
{
"pvforecast": {
"provider": "PVForecastVrm",
"provider_settings": {
"pvforecast_vrm_token": "dummy-token",
"pvforecast_vrm_idsite": 12345
}
}
```
The prediction keys for the PV forecast data are:
- `pvforecast_dc_power`: Total DC power (W).
### PVForecastImport Provider ### PVForecastImport Provider
The `PVForecastImport` provider is designed to import PV forecast data from a file or a JSON The `PVForecastImport` provider is designed to import PV forecast data from a file or a JSON
@@ -381,12 +476,15 @@ becomes available.
The prediction keys for the PV forecast data are: The prediction keys for the PV forecast data are:
- `pvforecast_ac_power`: Total DC power (W). - `pvforecast_ac_power`: Total AC power (W).
- `pvforecast_dc_power`: Total AC power (W). - `pvforecast_dc_power`: Total DC power (W).
The PV forecast data must be provided in one of the formats described in The PV forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source must be given in the <project:#prediction-import-providers>. The data source can be given in the
`pvforecastimport_file_path` or `pvforecastimport_json` configuration option. `import_file_path` or `import_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/PVForecastImport` endpoint.
## Weather Prediction ## Weather Prediction
@@ -417,14 +515,16 @@ Prediction keys:
Configuration options: Configuration options:
- `weather_provider`: Load provider id of provider to be used. - `weather`: General weather configuration.
- `BrightSky`: Retrieves from [BrightSky](https://api.brightsky.dev). - `provider`: Load provider id of provider to be used.
- `ClearOutside`: Retrieves from [ClearOutside](https://clearoutside.com/forecast).
- `LoadImport`: Imports from a file or JSON string.
- `weatherimport_file_path`: Path to the file to import weatherforecast data from. - `BrightSky`: Retrieves from [BrightSky](https://api.brightsky.dev).
- `weatherimport_json`: JSON string, dictionary of weather forecast value lists. - `ClearOutside`: Retrieves from [ClearOutside](https://clearoutside.com/forecast).
- `LoadImport`: Imports from a file or JSON string.
- `provider_settings.import_file_path`: Path to the file to import weatherforecast data from.
- `provider_settings.import_json`: JSON string, dictionary of weather forecast value lists.
### BrightSky Provider ### BrightSky Provider
@@ -481,7 +581,7 @@ The `WeatherImport` provider is designed to import weather forecast data from a
string. An external entity should update the file or JSON string whenever new prediction data string. An external entity should update the file or JSON string whenever new prediction data
becomes available. becomes available.
The prediction keys for the PV forecast data are: The prediction keys for the weather forecast data are:
- `weather_dew_point`: Dew Point (°C) - `weather_dew_point`: Dew Point (°C)
- `weather_dhi`: Diffuse Horizontal Irradiance (W/m2) - `weather_dhi`: Diffuse Horizontal Irradiance (W/m2)
@@ -507,5 +607,8 @@ The prediction keys for the PV forecast data are:
- `weather_wind_speed`: Wind Speed (kmph) - `weather_wind_speed`: Wind Speed (kmph)
The PV forecast data must be provided in one of the formats described in The PV forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source must be given in the <project:#prediction-import-providers>. The data source can be given in the
`weatherimport_file_path` or `pvforecastimport_json` configuration option. `import_file_path` or `import_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/WeatherImport` endpoint.

View File

@@ -99,6 +99,7 @@ html_theme_options = {
"logo_only": False, "logo_only": False,
"titles_only": True, "titles_only": True,
} }
html_css_files = ["eos.css"]
# -- Options for autodoc ------------------------------------------------- # -- Options for autodoc -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html

View File

@@ -40,6 +40,7 @@ akkudoktoreos/optimization.md
akkudoktoreos/prediction.md akkudoktoreos/prediction.md
akkudoktoreos/measurement.md akkudoktoreos/measurement.md
akkudoktoreos/integration.md akkudoktoreos/integration.md
akkudoktoreos/logging.md
akkudoktoreos/serverapi.md akkudoktoreos/serverapi.md
akkudoktoreos/api.rst akkudoktoreos/api.rst

File diff suppressed because it is too large Load Diff

View File

@@ -43,12 +43,18 @@ profile = "black"
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
exclude = [
"tests",
"scripts",
]
output-format = "full"
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = [
"F", # Enable all `Pyflakes` rules. "F", # Enable all `Pyflakes` rules.
"D", # Enable all `pydocstyle` rules, limiting to those that adhere to the "D", # Enable all `pydocstyle` rules, limiting to those that adhere to the
# Google convention via `convention = "google"`, below. # Google convention via `convention = "google"`, below.
"S", # Enable all `flake8-bandit` rules.
] ]
ignore = [ ignore = [
# Prevent errors due to ruff false positives # Prevent errors due to ruff false positives

View File

@@ -1,14 +1,15 @@
-r requirements.txt -r requirements.txt
gitpython==3.1.44 gitlint==0.19.1
linkify-it-py==2.0.3 GitPython==3.1.45
myst-parser==4.0.1 myst-parser==4.0.1
sphinx==8.2.3 sphinx==8.2.3
sphinx_rtd_theme==3.0.2 sphinx_rtd_theme==3.0.2
sphinx-tabs==3.4.7 sphinx-tabs==3.4.7
pytest==8.3.5 pymarkdownlnt==0.9.32
pytest-cov==6.0.0 pytest==8.4.2
pytest-cov==7.0.0
pytest-xprocess==1.0.2 pytest-xprocess==1.0.2
pre-commit pre-commit
mypy==1.15.0 mypy==1.18.2
types-requests==2.32.0.20250306 types-requests==2.32.4.20250913
pandas-stubs==2.2.3.250308 pandas-stubs==2.3.2.250827

View File

@@ -1,16 +1,25 @@
numpy==2.2.4 cachebox==5.0.2
numpydantic==1.6.8 numpy==2.3.3
matplotlib==3.10.1 numpydantic==1.6.11
fastapi[standard]==0.115.11 matplotlib==3.10.6
python-fasthtml==0.12.4 fastapi[standard]==0.115.14
uvicorn==0.34.0 python-fasthtml==0.12.29
scikit-learn==1.6.1 MonsterUI==1.0.29
timezonefinder==6.5.8 markdown-it-py==4.0.0
deap==1.4.2 mdit-py-plugins==0.5.0
requests==2.32.3 bokeh==3.8.0
pandas==2.2.3 uvicorn==0.36.0
pendulum==3.0.0 scikit-learn==1.7.2
platformdirs==4.3.7 timezonefinder==7.0.2
pvlib==0.12.0 deap==1.4.3
pydantic==2.10.6 requests==2.32.5
statsmodels==0.14.4 pandas==2.3.2
pendulum==3.1.0
platformdirs==4.4.0
psutil==7.1.0
pvlib==0.13.1
pydantic==2.11.9
statsmodels==0.14.5
pydantic-settings==2.11.0
linkify-it-py==2.0.3
loguru==0.7.3

View File

@@ -150,7 +150,7 @@ def main():
try: try:
if args.input_file: if args.input_file:
with open(args.input_file, "r", encoding="utf8") as f: with open(args.input_file, "r", encoding="utf-8", newline=None) as f:
content = f.read() content = f.read()
elif args.input: elif args.input:
content = args.input content = args.input
@@ -164,7 +164,7 @@ def main():
) )
if args.output_file: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf8") as f: with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
f.write(extracted_content) f.write(extracted_content)
else: else:
# Write to std output # Write to std output

View File

@@ -2,135 +2,294 @@
"""Utility functions for Configuration specification generation.""" """Utility functions for Configuration specification generation."""
import argparse import argparse
import json
import os
import re
import sys import sys
import textwrap
from pathlib import Path
from typing import Any, Union
from akkudoktoreos.config.config import get_config from loguru import logger
from akkudoktoreos.core.logging import get_logger from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
logger = get_logger(__name__) from akkudoktoreos.config.config import ConfigEOS, GeneralSettings, get_config
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.utils.docs import get_model_structure_from_examples
config_eos = get_config() documented_types: set[PydanticBaseModel] = set()
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
# Fixed set of prefixes to filter configuration values and their respective titles global_config_dict: dict[str, Any] = dict()
CONFIG_PREFIXES = {
"battery": "Battery Device Simulation Configuration",
"bev": "Battery Electric Vehicle Device Simulation Configuration",
"dishwasher": "Dishwasher Device Simulation Configuration",
"inverter": "Inverter Device Simulation Configuration",
"measurement": "Measurement Configuration",
"optimization": "General Optimization Configuration",
"server": "Server Configuration",
"elecprice": "Electricity Price Prediction Configuration",
"load": "Load Prediction Configuration",
"logging": "Logging Configuration",
"prediction": "General Prediction Configuration",
"pvforecast": "PV Forecast Configuration",
"weather": "Weather Forecast Configuration",
}
# Static set of configuration names to include in a separate table
GENERAL_CONFIGS = [
"config_default_file_path",
"config_file_path",
"config_folder_path",
"config_keys",
"config_keys_read_only",
"data_cache_path",
"data_cache_subpath",
"data_folder_path",
"data_output_path",
"data_output_subpath",
"latitude",
"longitude",
"package_root_path",
"timezone",
]
def generate_config_table_md(configs, title): def get_title(config: PydanticBaseModel) -> str:
if config.__doc__ is None:
raise NameError(f"Missing docstring: {config}")
return config.__doc__.strip().splitlines()[0].strip(".")
def get_body(config: PydanticBaseModel) -> str:
if config.__doc__ is None:
raise NameError(f"Missing docstring: {config}")
return textwrap.dedent("\n".join(config.__doc__.strip().splitlines()[1:])).strip()
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
resolved_types: list[tuple[type, list[str]]] = []
origin = getattr(field_type, "__origin__", field_type)
if origin is Union:
for arg in getattr(field_type, "__args__", []):
resolved_types.extend(resolve_nested_types(arg, parent_types))
elif origin is list:
for arg in getattr(field_type, "__args__", []):
resolved_types.extend(resolve_nested_types(arg, parent_types + ["list"]))
else:
resolved_types.append((field_type, parent_types))
return resolved_types
def create_model_from_examples(
model_class: PydanticBaseModel, multiple: bool
) -> list[PydanticBaseModel]:
"""Create a model instance with default or example values, respecting constraints."""
return [
model_class(**data) for data in get_model_structure_from_examples(model_class, multiple)
]
def build_nested_structure(keys: list[str], value: Any) -> Any:
if not keys:
return value
current_key = keys[0]
if current_key == "list":
return [build_nested_structure(keys[1:], value)]
else:
return {current_key: build_nested_structure(keys[1:], value)}
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
default_value = ""
if regular_field:
if (val := field_info.default) is not PydanticUndefined:
default_value = val
else:
default_value = "required"
else:
default_value = "N/A"
return default_value
def get_type_name(field_type: type) -> str:
type_name = str(field_type).replace("typing.", "").replace("pathlib._local", "pathlib")
if type_name.startswith("<class"):
type_name = field_type.__name__
return type_name
def generate_config_table_md(
config: PydanticBaseModel,
toplevel_keys: list[str],
prefix: str,
toplevel: bool = False,
extra_config: bool = False,
) -> str:
"""Generate a markdown table for given configurations. """Generate a markdown table for given configurations.
Args: Args:
configs (dict): Configuration values with keys and their descriptions. config (PydanticBaseModel): PydanticBaseModel configuration definition.
title (str): Title for the table. prefix (str): Prefix for table entries.
Returns: Returns:
str: The markdown table as a string. str: The markdown table as a string.
""" """
if not configs: table = ""
return "" if toplevel:
title = get_title(config)
heading_level = "###" if extra_config else "##"
env_header = ""
env_header_underline = ""
env_width = ""
if not extra_config:
env_header = "| Environment Variable "
env_header_underline = "| -------------------- "
env_width = "20 "
table += f"{heading_level} {title}\n\n"
body = get_body(config)
if body:
table += body
table += "\n\n"
table += (
":::{table} "
+ f"{'::'.join(toplevel_keys)}\n:widths: 10 {env_width}10 5 5 30\n:align: left\n\n"
)
table += f"| Name {env_header}| Type | Read-Only | Default | Description |\n"
table += f"| ---- {env_header_underline}| ---- | --------- | ------- | ----------- |\n"
for field_name, field_info in list(config.model_fields.items()) + list(
config.model_computed_fields.items()
):
regular_field = isinstance(field_info, FieldInfo)
config_name = field_name if extra_config else field_name.upper()
field_type = field_info.annotation if regular_field else field_info.return_type
default_value = get_default_value(field_info, regular_field)
description = field_info.description if field_info.description else "-"
deprecated = field_info.deprecated if field_info.deprecated else None
read_only = "rw" if regular_field else "ro"
type_name = get_type_name(field_type)
env_entry = ""
if not extra_config:
if regular_field:
env_entry = f"| `{prefix}{config_name}` "
else:
env_entry = "| "
if deprecated:
if isinstance(deprecated, bool):
description = "Deprecated!"
else:
description = deprecated
table += f"| {field_name} {env_entry}| `{type_name}` | `{read_only}` | `{default_value}` | {description} |\n"
inner_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
def extract_nested_models(subtype: Any, subprefix: str, parent_types: list[str]):
if subtype in inner_types.keys():
return
nested_types = resolve_nested_types(subtype, [])
for nested_type, nested_parent_types in nested_types:
if issubclass(nested_type, PydanticBaseModel):
new_parent_types = parent_types + nested_parent_types
if "list" in parent_types:
new_prefix = ""
else:
new_prefix = f"{subprefix}"
inner_types.setdefault(nested_type, (new_prefix, new_parent_types))
for nested_field_name, nested_field_info in list(
nested_type.model_fields.items()
) + list(nested_type.model_computed_fields.items()):
nested_field_type = nested_field_info.annotation
if new_prefix:
new_prefix += f"{nested_field_name.upper()}__"
extract_nested_models(
nested_field_type,
new_prefix,
new_parent_types + [nested_field_name],
)
extract_nested_models(field_type, f"{prefix}{config_name}__", toplevel_keys + [field_name])
for new_type, info in inner_types.items():
if new_type not in documented_types:
undocumented_types.setdefault(new_type, (info[0], info[1]))
if toplevel:
table += ":::\n\n" # Add an empty line after the table
has_examples_list = toplevel_keys[-1] == "list"
instance_list = create_model_from_examples(config, has_examples_list)
if instance_list:
ins_dict_list = []
ins_out_dict_list = []
for ins in instance_list:
# Transform to JSON (and manually to dict) to use custom serializers and then merge with parent keys
ins_json = ins.model_dump_json(include_computed_fields=False)
ins_dict_list.append(json.loads(ins_json))
ins_out_json = ins.model_dump_json(include_computed_fields=True)
ins_out_dict_list.append(json.loads(ins_out_json))
same_output = ins_out_dict_list == ins_dict_list
same_output_str = "/Output" if same_output else ""
table += f"#{heading_level} Example Input{same_output_str}\n\n"
table += "```{eval-rst}\n"
table += ".. code-block:: json\n\n"
if has_examples_list:
input_dict = build_nested_structure(toplevel_keys[:-1], ins_dict_list)
if not extra_config:
global_config_dict[toplevel_keys[0]] = ins_dict_list
else:
input_dict = build_nested_structure(toplevel_keys, ins_dict_list[0])
if not extra_config:
global_config_dict[toplevel_keys[0]] = ins_dict_list[0]
table += textwrap.indent(json.dumps(input_dict, indent=4), " ")
table += "\n"
table += "```\n\n"
if not same_output:
table += f"#{heading_level} Example Output\n\n"
table += "```{eval-rst}\n"
table += ".. code-block:: json\n\n"
if has_examples_list:
output_dict = build_nested_structure(toplevel_keys[:-1], ins_out_dict_list)
else:
output_dict = build_nested_structure(toplevel_keys, ins_out_dict_list[0])
table += textwrap.indent(json.dumps(output_dict, indent=4), " ")
table += "\n"
table += "```\n\n"
while undocumented_types:
extra_config_type, extra_info = undocumented_types.popitem()
documented_types.add(extra_config_type)
table += generate_config_table_md(
extra_config_type, extra_info[1], extra_info[0], True, True
)
table = f"## {title}\n\n"
table += ":::{table} " + f"{title}\n:widths: 10 10 5 5 30\n:align: left\n\n"
table += "| Name | Type | Read-Only | Default | Description |\n"
table += "| ---- | ---- | --------- | ------- | ----------- |\n"
for name, config in sorted(configs.items()):
type_name = config["type"]
if type_name.startswith("typing."):
type_name = type_name[len("typing.") :]
table += f"| `{config['name']}` | `{type_name}` | `{config['read-only']}` | `{config['default']}` | {config['description']} |\n"
table += ":::\n\n" # Add an empty line after the table
return table return table
def generate_config_md() -> str: def generate_config_md(config_eos: ConfigEOS) -> str:
"""Generate configuration specification in Markdown with extra tables for prefixed values. """Generate configuration specification in Markdown with extra tables for prefixed values.
Returns: Returns:
str: The Markdown representation of the configuration spec. str: The Markdown representation of the configuration spec.
""" """
configs = {} # Fix file path for general settings to not show local/test file path
config_keys = config_eos.config_keys GeneralSettings._config_file_path = Path(
config_keys_read_only = config_eos.config_keys_read_only "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
for config_key in config_keys: )
config = {} GeneralSettings._config_folder_path = config_eos.general.config_file_path.parent
config["name"] = config_key
config["value"] = getattr(config_eos, config_key)
if config_key in config_keys_read_only:
config["read-only"] = "ro"
computed_field_info = config_eos.__pydantic_decorators__.computed_fields[
config_key
].info
config["default"] = "N/A"
config["description"] = computed_field_info.description
config["type"] = str(computed_field_info.return_type)
else:
config["read-only"] = "rw"
field_info = config_eos.model_fields[config_key]
config["default"] = field_info.default
config["description"] = field_info.description
config["type"] = str(field_info.annotation)
configs[config_key] = config
# Generate markdown for the main table
markdown = "# Configuration Table\n\n" markdown = "# Configuration Table\n\n"
# Generate table for general configuration names # Generate tables for each top level config
general_configs = {k: v for k, v in configs.items() if k in GENERAL_CONFIGS} for field_name, field_info in config_eos.__class__.model_fields.items():
for k in general_configs.keys(): field_type = field_info.annotation
del configs[k] # Remove general configs from the main configs dictionary markdown += generate_config_table_md(
markdown += generate_config_table_md(general_configs, "General Configuration Values") field_type, [field_name], f"EOS_{field_name.upper()}__", True
)
non_prefixed_configs = {k: v for k, v in configs.items()} # Full config
markdown += "## Full example Config\n\n"
markdown += "```{eval-rst}\n"
markdown += ".. code-block:: json\n\n"
# Test for valid config first
config_eos.merge_settings_from_dict(global_config_dict)
markdown += textwrap.indent(json.dumps(global_config_dict, indent=4), " ")
markdown += "\n"
markdown += "```\n\n"
# Generate tables for each prefix (sorted by value) and remove prefixed configs from the main dictionary # Assure there is no double \n at end of file
sorted_prefixes = sorted(CONFIG_PREFIXES.items(), key=lambda item: item[1])
for prefix, title in sorted_prefixes:
prefixed_configs = {k: v for k, v in configs.items() if k.startswith(prefix)}
for k in prefixed_configs.keys():
del non_prefixed_configs[k]
markdown += generate_config_table_md(prefixed_configs, title)
# Generate markdown for the remaining non-prefixed configs if any
if non_prefixed_configs:
markdown += generate_config_table_md(non_prefixed_configs, "Other Configuration Values")
# Assure the is no double \n at end of file
markdown = markdown.rstrip("\n") markdown = markdown.rstrip("\n")
markdown += "\n" markdown += "\n"
# Assure log path does not leak to documentation
markdown = re.sub(
r'(?<=["\'])/[^"\']*/output/eos\.log(?=["\'])',
'/home/user/.local/share/net.akkudoktoreos.net/output/eos.log',
markdown
)
return markdown return markdown
@@ -145,12 +304,15 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
config_eos = get_config()
try: try:
config_md = generate_config_md() config_md = generate_config_md(config_eos)
if os.name == "nt":
config_md = config_md.replace("\\\\", "/")
if args.output_file: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf8") as f: with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
f.write(config_md) f.write(config_md)
else: else:
# Write to std output # Write to std output
@@ -158,7 +320,8 @@ def main():
except Exception as e: except Exception as e:
print(f"Error during Configuration Specification generation: {e}", file=sys.stderr) print(f"Error during Configuration Specification generation: {e}", file=sys.stderr)
sys.exit(1) # keep throwing error to debug potential problems (e.g. invalid examples)
raise e
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -16,6 +16,7 @@ Example:
import argparse import argparse
import json import json
import os
import sys import sys
from fastapi.openapi.utils import get_openapi from fastapi.openapi.utils import get_openapi
@@ -37,6 +38,14 @@ def generate_openapi() -> dict:
routes=app.routes, routes=app.routes,
) )
# Fix file path for general settings to not show local/test file path
general = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["general"]["default"]
general["config_file_path"] = "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
general["config_folder_path"] = "/home/user/.config/net.akkudoktoreos.net"
# Fix file path for logging settings to not show local/test file path
logging = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["logging"]["default"]
logging["file_path"] = "/home/user/.local/share/net.akkudoktoreos.net/output/eos.log"
return openapi_spec return openapi_spec
@@ -54,7 +63,7 @@ def main():
openapi_spec_str = json.dumps(openapi_spec, indent=2) openapi_spec_str = json.dumps(openapi_spec, indent=2)
if args.output_file: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf8") as f: with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
f.write(openapi_spec_str) f.write(openapi_spec_str)
else: else:
# Write to std output # Write to std output

View File

@@ -3,6 +3,7 @@
import argparse import argparse
import json import json
import os
import sys import sys
import git import git
@@ -284,9 +285,11 @@ def main():
try: try:
openapi_md = generate_openapi_md() openapi_md = generate_openapi_md()
if os.name == "nt":
openapi_md = openapi_md.replace("127.0.0.1", "127.0.0.1")
if args.output_file: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf8") as f: with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
f.write(openapi_md) f.write(openapi_md)
else: else:
# Write to std output # Write to std output

View File

@@ -0,0 +1 @@
# Placeholder for gitlint user rules (see https://jorisroovers.com/gitlint/latest/rules/user_defined_rules/).

View File

@@ -12,15 +12,12 @@ import numpy as np
from akkudoktoreos.config.config import get_config from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.optimization.genetic import ( from akkudoktoreos.optimization.genetic import (
OptimizationParameters, OptimizationParameters,
optimization_problem, optimization_problem,
) )
from akkudoktoreos.prediction.prediction import get_prediction from akkudoktoreos.prediction.prediction import get_prediction
get_logger(__name__, logging_level="DEBUG")
def prepare_optimization_real_parameters() -> OptimizationParameters: def prepare_optimization_real_parameters() -> OptimizationParameters:
"""Prepare and return optimization parameters with real world data. """Prepare and return optimization parameters with real world data.
@@ -30,42 +27,63 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
""" """
# Make a config # Make a config
settings = { settings = {
# -- General -- "general": {
"prediction_hours": 48, "latitude": 52.52,
"prediction_historic_hours": 24, "longitude": 13.405,
"latitude": 52.52, },
"longitude": 13.405, "prediction": {
# -- Predictions -- "hours": 48,
"historic_hours": 24,
},
# PV Forecast # PV Forecast
"pvforecast_provider": "PVForecastAkkudoktor", "pvforecast": {
"pvforecast0_peakpower": 5.0, "provider": "PVForecastAkkudoktor",
"pvforecast0_surface_azimuth": -10, "planes": [
"pvforecast0_surface_tilt": 7, {
"pvforecast0_userhorizon": [20, 27, 22, 20], "peakpower": 5.0,
"pvforecast0_inverter_paco": 10000, "surface_azimuth": -10,
"pvforecast1_peakpower": 4.8, "surface_tilt": 7,
"pvforecast1_surface_azimuth": -90, "userhorizon": [20, 27, 22, 20],
"pvforecast1_surface_tilt": 7, "inverter_paco": 10000,
"pvforecast1_userhorizon": [30, 30, 30, 50], },
"pvforecast1_inverter_paco": 10000, {
"pvforecast2_peakpower": 1.4, "peakpower": 4.8,
"pvforecast2_surface_azimuth": -40, "surface_azimuth": -90,
"pvforecast2_surface_tilt": 60, "surface_tilt": 7,
"pvforecast2_userhorizon": [60, 30, 0, 30], "userhorizon": [30, 30, 30, 50],
"pvforecast2_inverter_paco": 2000, "inverter_paco": 10000,
"pvforecast3_peakpower": 1.6, },
"pvforecast3_surface_azimuth": 5, {
"pvforecast3_surface_tilt": 45, "peakpower": 1.4,
"pvforecast3_userhorizon": [45, 25, 30, 60], "surface_azimuth": -40,
"pvforecast3_inverter_paco": 1400, "surface_tilt": 60,
"pvforecast4_peakpower": None, "userhorizon": [60, 30, 0, 30],
"inverter_paco": 2000,
},
{
"peakpower": 1.6,
"surface_azimuth": 5,
"surface_tilt": 45,
"userhorizon": [45, 25, 30, 60],
"inverter_paco": 1400,
},
],
},
# Weather Forecast # Weather Forecast
"weather_provider": "ClearOutside", "weather": {
"provider": "ClearOutside",
},
# Electricity Price Forecast # Electricity Price Forecast
"elecprice_provider": "ElecPriceAkkudoktor", "elecprice": {
"provider": "ElecPriceAkkudoktor",
},
# Load Forecast # Load Forecast
"load_provider": "LoadAkkudoktor", "load": {
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh "provider": "LoadAkkudoktor",
"provider_settings": {
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
},
},
# -- Simulations -- # -- Simulations --
} }
config_eos = get_config() config_eos = get_config()
@@ -129,20 +147,20 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
"strompreis_euro_pro_wh": strompreis_euro_pro_wh, "strompreis_euro_pro_wh": strompreis_euro_pro_wh,
}, },
"pv_akku": { "pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400, "capacity_wh": 26400,
"initial_soc_percentage": 15, "initial_soc_percentage": 15,
"min_soc_percentage": 15, "min_soc_percentage": 15,
}, },
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery_id": "battery1"},
"eauto": { "eauto": {
"device_id": "ev1",
"min_soc_percentage": 50, "min_soc_percentage": 50,
"capacity_wh": 60000, "capacity_wh": 60000,
"charging_efficiency": 0.95, "charging_efficiency": 0.95,
"max_charge_power_w": 11040, "max_charge_power_w": 11040,
"initial_soc_percentage": 5, "initial_soc_percentage": 5,
}, },
"inverter": {
"max_power_wh": 10000,
},
"temperature_forecast": temperature_forecast, "temperature_forecast": temperature_forecast,
"start_solution": start_solution, "start_solution": start_solution,
} }
@@ -283,20 +301,20 @@ def prepare_optimization_parameters() -> OptimizationParameters:
"strompreis_euro_pro_wh": strompreis_euro_pro_wh, "strompreis_euro_pro_wh": strompreis_euro_pro_wh,
}, },
"pv_akku": { "pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400, "capacity_wh": 26400,
"initial_soc_percentage": 15, "initial_soc_percentage": 15,
"min_soc_percentage": 15, "min_soc_percentage": 15,
}, },
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery_id": "battery1"},
"eauto": { "eauto": {
"device_id": "ev1",
"min_soc_percentage": 50, "min_soc_percentage": 50,
"capacity_wh": 60000, "capacity_wh": 60000,
"charging_efficiency": 0.95, "charging_efficiency": 0.95,
"max_charge_power_w": 11040, "max_charge_power_w": 11040,
"initial_soc_percentage": 5, "initial_soc_percentage": 5,
}, },
"inverter": {
"max_power_wh": 10000,
},
"temperature_forecast": temperature_forecast, "temperature_forecast": temperature_forecast,
"start_solution": start_solution, "start_solution": start_solution,
} }
@@ -330,7 +348,9 @@ def run_optimization(
# Initialize the optimization problem using the default configuration # Initialize the optimization problem using the default configuration
config_eos = get_config() config_eos = get_config()
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48}) config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"hours": 48}}
)
opt_class = optimization_problem(verbose=verbose, fixed_seed=seed) opt_class = optimization_problem(verbose=verbose, fixed_seed=seed)
# Perform the optimisation based on the provided parameters and start hour # Perform the optimisation based on the provided parameters and start hour

View File

@@ -16,32 +16,47 @@ prediction_eos = get_prediction()
def config_pvforecast() -> dict: def config_pvforecast() -> dict:
"""Configure settings for PV forecast.""" """Configure settings for PV forecast."""
settings = { settings = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24, "latitude": 52.52,
"latitude": 52.52, "longitude": 13.405,
"longitude": 13.405, },
"pvforecast_provider": "PVForecastAkkudoktor", "prediction": {
"pvforecast0_peakpower": 5.0, "hours": 48,
"pvforecast0_surface_azimuth": -10, "historic_hours": 24,
"pvforecast0_surface_tilt": 7, },
"pvforecast0_userhorizon": [20, 27, 22, 20], "pvforecast": {
"pvforecast0_inverter_paco": 10000, "provider": "PVForecastAkkudoktor",
"pvforecast1_peakpower": 4.8, "planes": [
"pvforecast1_surface_azimuth": -90, {
"pvforecast1_surface_tilt": 7, "peakpower": 5.0,
"pvforecast1_userhorizon": [30, 30, 30, 50], "surface_azimuth": -10,
"pvforecast1_inverter_paco": 10000, "surface_tilt": 7,
"pvforecast2_peakpower": 1.4, "userhorizon": [20, 27, 22, 20],
"pvforecast2_surface_azimuth": -40, "inverter_paco": 10000,
"pvforecast2_surface_tilt": 60, },
"pvforecast2_userhorizon": [60, 30, 0, 30], {
"pvforecast2_inverter_paco": 2000, "peakpower": 4.8,
"pvforecast3_peakpower": 1.6, "surface_azimuth": -90,
"pvforecast3_surface_azimuth": 5, "surface_tilt": 7,
"pvforecast3_surface_tilt": 45, "userhorizon": [30, 30, 30, 50],
"pvforecast3_userhorizon": [45, 25, 30, 60], "inverter_paco": 10000,
"pvforecast3_inverter_paco": 1400, },
"pvforecast4_peakpower": None, {
"peakpower": 1.4,
"surface_azimuth": -40,
"surface_tilt": 60,
"userhorizon": [60, 30, 0, 30],
"inverter_paco": 2000,
},
{
"peakpower": 1.6,
"surface_azimuth": 5,
"surface_tilt": 45,
"userhorizon": [45, 25, 30, 60],
"inverter_paco": 1400,
},
],
},
} }
return settings return settings
@@ -49,10 +64,15 @@ def config_pvforecast() -> dict:
def config_weather() -> dict: def config_weather() -> dict:
"""Configure settings for weather forecast.""" """Configure settings for weather forecast."""
settings = { settings = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24, "latitude": 52.52,
"latitude": 52.52, "longitude": 13.405,
"longitude": 13.405, },
"prediction": {
"hours": 48,
"historic_hours": 24,
},
"weather": dict(),
} }
return settings return settings
@@ -60,10 +80,15 @@ def config_weather() -> dict:
def config_elecprice() -> dict: def config_elecprice() -> dict:
"""Configure settings for electricity price forecast.""" """Configure settings for electricity price forecast."""
settings = { settings = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24, "latitude": 52.52,
"latitude": 52.52, "longitude": 13.405,
"longitude": 13.405, },
"prediction": {
"hours": 48,
"historic_hours": 24,
},
"elecprice": dict(),
} }
return settings return settings
@@ -71,10 +96,14 @@ def config_elecprice() -> dict:
def config_load() -> dict: def config_load() -> dict:
"""Configure settings for load forecast.""" """Configure settings for load forecast."""
settings = { settings = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24, "latitude": 52.52,
"latitude": 52.52, "longitude": 13.405,
"longitude": 13.405, },
"prediction": {
"hours": 48,
"historic_hours": 24,
},
} }
return settings return settings
@@ -92,30 +121,40 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
# Initialize the oprediction # Initialize the oprediction
config_eos = get_config() config_eos = get_config()
prediction_eos = get_prediction() prediction_eos = get_prediction()
if verbose:
print(f"\nProvider ID: {provider_id}")
if provider_id in ("PVForecastAkkudoktor",): if provider_id in ("PVForecastAkkudoktor",):
settings = config_pvforecast() settings = config_pvforecast()
settings["pvforecast_provider"] = provider_id forecast = "pvforecast"
elif provider_id in ("BrightSky", "ClearOutside"): elif provider_id in ("BrightSky", "ClearOutside"):
settings = config_weather() settings = config_weather()
settings["weather_provider"] = provider_id forecast = "weather"
elif provider_id in ("ElecPriceAkkudoktor",): elif provider_id in ("ElecPriceAkkudoktor",):
settings = config_elecprice() settings = config_elecprice()
settings["elecprice_provider"] = provider_id forecast = "elecprice"
elif provider_id in ("LoadAkkudoktor",): elif provider_id in ("LoadAkkudoktor",):
settings = config_elecprice() settings = config_elecprice()
settings["loadakkudoktor_year_energy"] = 1000 forecast = "load"
settings["load_provider"] = provider_id settings["load"]["loadakkudoktor_year_energy"] = 1000
else: else:
raise ValueError(f"Unknown provider '{provider_id}'.") raise ValueError(f"Unknown provider '{provider_id}'.")
settings[forecast]["provider"] = provider_id
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
provider = prediction_eos.provider_by_id(provider_id)
prediction_eos.update_data() prediction_eos.update_data()
# Return result of prediction # Return result of prediction
provider = prediction_eos.provider_by_id(provider_id)
if verbose: if verbose:
print(f"\nProvider ID: {provider.provider_id()}")
print("----------")
print("\nSettings\n----------")
print(settings)
print("\nProvider\n----------")
print(f"elecprice.provider: {config_eos.elecprice.provider}")
print(f"load.provider: {config_eos.load.provider}")
print(f"pvforecast.provider: {config_eos.pvforecast.provider}")
print(f"weather.provider: {config_eos.weather.provider}")
print(f"enabled: {provider.enabled()}")
for key in provider.record_keys: for key in provider.record_keys:
print(f"\n{key}\n----------") print(f"\n{key}\n----------")
print(f"Array: {provider.key_to_array(key)}") print(f"Array: {provider.key_to_array(key)}")

View File

@@ -12,34 +12,38 @@ Key features:
import os import os
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar, List, Optional from typing import Any, ClassVar, Optional, Type
from loguru import logger
from platformdirs import user_config_dir, user_data_dir from platformdirs import user_config_dir, user_data_dir
from pydantic import Field, ValidationError, computed_field from pydantic import Field, computed_field
from pydantic_settings import (
BaseSettings,
JsonConfigSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
# settings # settings
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cachesettings import CacheCommonSettings
from akkudoktoreos.core.coreabc import SingletonMixin from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.decorators import classproperty
from akkudoktoreos.core.emsettings import EnergyManagementCommonSettings
from akkudoktoreos.core.logsettings import LoggingCommonSettings from akkudoktoreos.core.logsettings import LoggingCommonSettings
from akkudoktoreos.devices.devices import DevicesCommonSettings from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, merge_models
from akkudoktoreos.devices.settings import DevicesCommonSettings
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
from akkudoktoreos.prediction.prediction import PredictionCommonSettings from akkudoktoreos.prediction.prediction import PredictionCommonSettings
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
from akkudoktoreos.prediction.weather import WeatherCommonSettings from akkudoktoreos.prediction.weather import WeatherCommonSettings
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
from akkudoktoreos.server.server import ServerCommonSettings from akkudoktoreos.server.server import ServerCommonSettings
from akkudoktoreos.utils.datetimeutil import to_timezone
from akkudoktoreos.utils.utils import UtilsCommonSettings from akkudoktoreos.utils.utils import UtilsCommonSettings
logger = get_logger(__name__)
def get_absolute_path( def get_absolute_path(
basepath: Optional[Path | str], subpath: Optional[Path | str] basepath: Optional[Path | str], subpath: Optional[Path | str]
@@ -59,61 +63,169 @@ def get_absolute_path(
return None return None
class ConfigCommonSettings(SettingsBaseModel): class GeneralSettings(SettingsBaseModel):
"""Settings for common configuration.""" """Settings for common configuration.
General configuration to set directories of cache and output files and system location (latitude
and longitude).
Validators ensure each parameter is within a specified range. A computed property, `timezone`,
determines the time zone based on latitude and longitude.
Attributes:
latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.
longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.
Properties:
timezone (Optional[str]): Computed time zone string based on the specified latitude
and longitude.
"""
_config_folder_path: ClassVar[Optional[Path]] = None
_config_file_path: ClassVar[Optional[Path]] = None
data_folder_path: Optional[Path] = Field( data_folder_path: Optional[Path] = Field(
default=None, description="Path to EOS data directory." default=None, description="Path to EOS data directory.", examples=[None, "/home/eos/data"]
) )
data_output_subpath: Optional[Path] = Field( data_output_subpath: Optional[Path] = Field(
"output", description="Sub-path for the EOS output data directory." default="output", description="Sub-path for the EOS output data directory."
) )
data_cache_subpath: Optional[Path] = Field( latitude: Optional[float] = Field(
"cache", description="Sub-path for the EOS cache data directory." default=52.52,
ge=-90.0,
le=90.0,
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
)
longitude: Optional[float] = Field(
default=13.405,
ge=-180.0,
le=180.0,
description="Longitude in decimal degrees, within -180 to 180 (°)",
) )
# Computed fields # Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def timezone(self) -> Optional[str]:
"""Compute timezone based on latitude and longitude."""
if self.latitude and self.longitude:
return to_timezone(location=(self.latitude, self.longitude), as_string=True)
return None
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def data_output_path(self) -> Optional[Path]: def data_output_path(self) -> Optional[Path]:
"""Compute data_output_path based on data_folder_path.""" """Compute data_output_path based on data_folder_path."""
return get_absolute_path(self.data_folder_path, self.data_output_subpath) return get_absolute_path(self.data_folder_path, self.data_output_subpath)
# Computed fields
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def data_cache_path(self) -> Optional[Path]: def config_folder_path(self) -> Optional[Path]:
"""Compute data_cache_path based on data_folder_path.""" """Path to EOS configuration directory."""
return get_absolute_path(self.data_folder_path, self.data_cache_subpath) return self._config_folder_path
@computed_field # type: ignore[prop-decorator]
@property
def config_file_path(self) -> Optional[Path]:
"""Path to EOS configuration file."""
return self._config_file_path
class SettingsEOS( class SettingsEOS(BaseSettings, PydanticModelNestedValueMixin):
ConfigCommonSettings, """Settings for all EOS.
LoggingCommonSettings,
DevicesCommonSettings,
MeasurementCommonSettings,
OptimizationCommonSettings,
PredictionCommonSettings,
ElecPriceCommonSettings,
ElecPriceImportCommonSettings,
LoadCommonSettings,
LoadAkkudoktorCommonSettings,
LoadImportCommonSettings,
PVForecastCommonSettings,
PVForecastImportCommonSettings,
WeatherCommonSettings,
WeatherImportCommonSettings,
ServerCommonSettings,
UtilsCommonSettings,
):
"""Settings for all EOS."""
pass Used by updating the configuration with specific settings only.
"""
general: Optional[GeneralSettings] = Field(
default=None,
description="General Settings",
)
cache: Optional[CacheCommonSettings] = Field(
default=None,
description="Cache Settings",
)
ems: Optional[EnergyManagementCommonSettings] = Field(
default=None,
description="Energy Management Settings",
)
logging: Optional[LoggingCommonSettings] = Field(
default=None,
description="Logging Settings",
)
devices: Optional[DevicesCommonSettings] = Field(
default=None,
description="Devices Settings",
)
measurement: Optional[MeasurementCommonSettings] = Field(
default=None,
description="Measurement Settings",
)
optimization: Optional[OptimizationCommonSettings] = Field(
default=None,
description="Optimization Settings",
)
prediction: Optional[PredictionCommonSettings] = Field(
default=None,
description="Prediction Settings",
)
elecprice: Optional[ElecPriceCommonSettings] = Field(
default=None,
description="Electricity Price Settings",
)
load: Optional[LoadCommonSettings] = Field(
default=None,
description="Load Settings",
)
pvforecast: Optional[PVForecastCommonSettings] = Field(
default=None,
description="PV Forecast Settings",
)
weather: Optional[WeatherCommonSettings] = Field(
default=None,
description="Weather Settings",
)
server: Optional[ServerCommonSettings] = Field(
default=None,
description="Server Settings",
)
utils: Optional[UtilsCommonSettings] = Field(
default=None,
description="Utilities Settings",
)
model_config = SettingsConfigDict(
env_nested_delimiter="__",
nested_model_default_partial_update=True,
env_prefix="EOS_",
ignored_types=(classproperty,),
)
class ConfigEOS(SingletonMixin, SettingsEOS): class SettingsEOSDefaults(SettingsEOS):
"""Settings for all of EOS with defaults.
Used by ConfigEOS instance to make all fields available.
"""
general: GeneralSettings = GeneralSettings()
cache: CacheCommonSettings = CacheCommonSettings()
ems: EnergyManagementCommonSettings = EnergyManagementCommonSettings()
logging: LoggingCommonSettings = LoggingCommonSettings()
devices: DevicesCommonSettings = DevicesCommonSettings()
measurement: MeasurementCommonSettings = MeasurementCommonSettings()
optimization: OptimizationCommonSettings = OptimizationCommonSettings()
prediction: PredictionCommonSettings = PredictionCommonSettings()
elecprice: ElecPriceCommonSettings = ElecPriceCommonSettings()
load: LoadCommonSettings = LoadCommonSettings()
pvforecast: PVForecastCommonSettings = PVForecastCommonSettings()
weather: WeatherCommonSettings = WeatherCommonSettings()
server: ServerCommonSettings = ServerCommonSettings()
utils: UtilsCommonSettings = UtilsCommonSettings()
class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""Singleton configuration handler for the EOS application. """Singleton configuration handler for the EOS application.
ConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic ConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic
@@ -143,8 +255,6 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
in one part of the application reflects across all references to this class. in one part of the application reflects across all references to this class.
Attributes: Attributes:
_settings (ClassVar[SettingsEOS]): Holds application-wide settings.
_file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file.
config_folder_path (Optional[Path]): Path to the configuration directory. config_folder_path (Optional[Path]): Path to the configuration directory.
config_file_path (Optional[Path]): Path to the configuration file. config_file_path (Optional[Path]): Path to the configuration file.
@@ -155,7 +265,7 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
To initialize and access configuration attributes (only one instance is created): To initialize and access configuration attributes (only one instance is created):
```python ```python
config_eos = ConfigEOS() # Always returns the same instance config_eos = ConfigEOS() # Always returns the same instance
print(config_eos.prediction_hours) # Access a setting from the loaded configuration print(config_eos.prediction.hours) # Access a setting from the loaded configuration
``` ```
""" """
@@ -167,111 +277,141 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
ENCODING: ClassVar[str] = "UTF-8" ENCODING: ClassVar[str] = "UTF-8"
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json" CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
_settings: ClassVar[Optional[SettingsEOS]] = None def __hash__(self) -> int:
_file_settings: ClassVar[Optional[SettingsEOS]] = None # ConfigEOS is a singleton
return hash("config_eos")
_config_folder_path: Optional[Path] = None def __eq__(self, other: Any) -> bool:
_config_file_path: Optional[Path] = None if not isinstance(other, ConfigEOS):
return False
# ConfigEOS is a singleton
return True
# Computed fields @classmethod
@computed_field # type: ignore[prop-decorator] def settings_customise_sources(
@property cls,
def config_folder_path(self) -> Optional[Path]: settings_cls: Type[BaseSettings],
"""Path to EOS configuration directory.""" init_settings: PydanticBaseSettingsSource,
return self._config_folder_path env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
"""Customizes the order and handling of settings sources for a Pydantic BaseSettings subclass.
@computed_field # type: ignore[prop-decorator] This method determines the sources for application configuration settings, including
@property environment variables, dotenv files and JSON configuration files.
def config_file_path(self) -> Optional[Path]: It ensures that a default configuration file exists and creates one if necessary.
"""Path to EOS configuration file."""
return self._config_file_path
@computed_field # type: ignore[prop-decorator] Args:
@property settings_cls (Type[BaseSettings]): The Pydantic BaseSettings class for which sources are customized.
def config_default_file_path(self) -> Path: init_settings (PydanticBaseSettingsSource): The initial settings source, typically passed at runtime.
env_settings (PydanticBaseSettingsSource): Settings sourced from environment variables.
dotenv_settings (PydanticBaseSettingsSource): Settings sourced from a dotenv file.
file_secret_settings (PydanticBaseSettingsSource): Unused (needed for parent class interface).
Returns:
tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
Behavior:
1. Checks for the existence of a JSON configuration file in the expected location.
2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a
default configuration file to the location. If the copy fails, uses the default configuration file directly.
3. Creates a `JsonConfigSettingsSource` for both the configuration file and the default configuration file.
4. Updates class attributes `GeneralSettings._config_folder_path` and
`GeneralSettings._config_file_path` to reflect the determined paths.
5. Returns a tuple containing all provided and newly created settings sources in the desired order.
Notes:
- This method logs a warning if the default configuration file cannot be copied.
- It ensures that a fallback to the default configuration file is always possible.
"""
setting_sources = [
init_settings,
env_settings,
dotenv_settings,
]
file_settings: Optional[JsonConfigSettingsSource] = None
config_file, exists = cls._get_config_file_path()
config_dir = config_file.parent
if not exists:
config_dir.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(cls.config_default_file_path, config_file)
except Exception as exc:
logger.warning(f"Could not copy default config: {exc}. Using default config...")
config_file = cls.config_default_file_path
config_dir = config_file.parent
try:
file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file)
setting_sources.append(file_settings)
except Exception as e:
logger.error(
f"Error reading config file '{config_file}' (falling back to default config): {e}"
)
default_settings = JsonConfigSettingsSource(
settings_cls, json_file=cls.config_default_file_path
)
GeneralSettings._config_folder_path = config_dir
GeneralSettings._config_file_path = config_file
setting_sources.append(default_settings)
return tuple(setting_sources)
@classproperty
def config_default_file_path(cls) -> Path:
"""Compute the default config file path.""" """Compute the default config file path."""
return self.package_root_path.joinpath("data/default.config.json") return cls.package_root_path.joinpath("data/default.config.json")
@computed_field # type: ignore[prop-decorator] @classproperty
@property def package_root_path(cls) -> Path:
def package_root_path(self) -> Path:
"""Compute the package root path.""" """Compute the package root path."""
return Path(__file__).parent.parent.resolve() return Path(__file__).parent.parent.resolve()
# Computed fields def __init__(self, *args: Any, **kwargs: Any) -> None:
@computed_field # type: ignore[prop-decorator]
@property
def config_keys(self) -> List[str]:
"""Returns the keys of all fields in the configuration."""
key_list = []
key_list.extend(list(self.model_fields.keys()))
key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys()))
return key_list
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def config_keys_read_only(self) -> List[str]:
"""Returns the keys of all read only fields in the configuration."""
key_list = []
key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys()))
return key_list
def __init__(self) -> None:
"""Initializes the singleton ConfigEOS instance. """Initializes the singleton ConfigEOS instance.
Configuration data is loaded from a configuration file or a default one is created if none Configuration data is loaded from a configuration file or a default one is created if none
exists. exists.
""" """
super().__init__() if hasattr(self, "_initialized"):
self.from_config_file() return
self.update() self._setup(self, *args, **kwargs)
@property def _setup(self, *args: Any, **kwargs: Any) -> None:
def settings(self) -> Optional[SettingsEOS]: """Re-initialize global settings."""
"""Returns global settings for EOS. # Check for config file content/ version type
config_file, exists = self._get_config_file_path()
if exists:
with config_file.open("r", encoding="utf-8", newline=None) as f_config:
config_txt = f_config.read()
if '"directories": {' in config_txt or '"server_eos_host": ' in config_txt:
error_msg = f"Configuration file '{config_file}' is outdated. Please remove or update manually."
logger.error(error_msg)
raise ValueError(error_msg)
# Assure settings base knows EOS configuration
SettingsBaseModel.config = self
# (Re-)load settings
SettingsEOSDefaults.__init__(self, *args, **kwargs)
# Init config file and data folder pathes
self._create_initial_config_file()
self._update_data_folder_path()
Settings generally provide configuration for EOS and are typically set only once. def merge_settings(self, settings: SettingsEOS) -> None:
Returns:
SettingsEOS: The settings for EOS or None.
"""
return ConfigEOS._settings
@classmethod
def _merge_and_update_settings(cls, settings: SettingsEOS) -> None:
"""Merge new and available settings.
Args:
settings (SettingsEOS): The new settings to apply.
"""
for key in SettingsEOS.model_fields:
if value := getattr(settings, key, None):
setattr(cls._settings, key, value)
def merge_settings(self, settings: SettingsEOS, force: Optional[bool] = None) -> None:
"""Merges the provided settings into the global settings for EOS, with optional overwrite. """Merges the provided settings into the global settings for EOS, with optional overwrite.
Args: Args:
settings (SettingsEOS): The settings to apply globally. settings (SettingsEOS): The settings to apply globally.
force (Optional[bool]): If True, overwrites the existing settings completely.
If False, the new settings are merged to the existing ones with priority for
the new ones. Defaults to False.
Raises: Raises:
ValueError: If settings are already set and `force` is not True or ValueError: If the `settings` is not a `SettingsEOS` instance.
if the `settings` is not a `SettingsEOS` instance.
""" """
if not isinstance(settings, SettingsEOS): if not isinstance(settings, SettingsEOS):
raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.") error_msg = f"Settings must be an instance of SettingsEOS: '{settings}'."
logger.error(error_msg)
raise ValueError(error_msg)
if ConfigEOS._settings is None or force: self.merge_settings_from_dict(settings.model_dump(exclude_none=True, exclude_unset=True))
ConfigEOS._settings = settings
else:
self._merge_and_update_settings(settings)
# Update configuration after merging
self.update()
def merge_settings_from_dict(self, data: dict) -> None: def merge_settings_from_dict(self, data: dict) -> None:
"""Merges the provided dictionary data into the current instance. """Merges the provided dictionary data into the current instance.
@@ -289,141 +429,83 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
Example: Example:
>>> config = get_config() >>> config = get_config()
>>> new_data = {"prediction_hours": 24, "server_eos_port": 8000} >>> new_data = {"prediction": {"hours": 24}, "server": {"port": 8000}}
>>> config.merge_settings_from_dict(new_data) >>> config.merge_settings_from_dict(new_data)
""" """
# Create new settings instance with reset optional fields and merged data self._setup(**merge_models(self, data))
settings = SettingsEOS.from_dict(data)
self.merge_settings(settings)
def reset_settings(self) -> None: def reset_settings(self) -> None:
"""Reset all available settings. """Reset all changed settings to environment/config file defaults.
This functions basically deletes the settings provided before. This functions basically deletes the settings provided before.
""" """
ConfigEOS._settings = None self._setup()
def _create_initial_config_file(self) -> None:
if self.general.config_file_path and not self.general.config_file_path.exists():
self.general.config_file_path.parent.mkdir(parents=True, exist_ok=True)
try:
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f:
f.write(self.model_dump_json(indent=4))
except Exception as e:
logger.error(
f"Could not write configuration file '{self.general.config_file_path}': {e}"
)
def _update_data_folder_path(self) -> None: def _update_data_folder_path(self) -> None:
"""Updates path to the data directory.""" """Updates path to the data directory."""
# From Settings # From Settings
if self.settings and (data_dir := self.settings.data_folder_path): if data_dir := self.general.data_folder_path:
try: try:
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir self.general.data_folder_path = data_dir
return return
except: except Exception as e:
pass logger.warning(f"Could not setup data dir: {e}")
# From EOS_DIR env # From EOS_DIR env
env_dir = os.getenv(self.EOS_DIR) if env_dir := os.getenv(self.EOS_DIR):
if env_dir is not None:
try: try:
data_dir = Path(env_dir).resolve() data_dir = Path(env_dir).resolve()
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir self.general.data_folder_path = data_dir
return return
except: except Exception as e:
pass logger.warning(f"Could not setup data dir: {e}")
# From configuration file
if self._file_settings and (data_dir := self._file_settings.data_folder_path):
try:
data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir
return
except:
pass
# From platform specific default path # From platform specific default path
try: try:
data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR)) data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
if data_dir is not None: if data_dir is not None:
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir self.general.data_folder_path = data_dir
return return
except: except Exception as e:
pass logger.warning(f"Could not setup data dir: {e}")
# Current working directory # Current working directory
data_dir = Path.cwd() data_dir = Path.cwd()
self.data_folder_path = data_dir self.general.data_folder_path = data_dir
def _get_config_file_path(self) -> tuple[Path, bool]: @classmethod
"""Finds the a valid configuration file or returns the desired path for a new config file. def _get_config_file_path(cls) -> tuple[Path, bool]:
"""Find a valid configuration file or return the desired path for a new config file.
Returns: Returns:
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there tuple[Path, bool]: The path to the configuration file and if there is already a config file there
""" """
config_dirs = [] config_dirs = []
env_base_dir = os.getenv(self.EOS_DIR) env_base_dir = os.getenv(cls.EOS_DIR)
env_config_dir = os.getenv(self.EOS_CONFIG_DIR) env_config_dir = os.getenv(cls.EOS_CONFIG_DIR)
env_dir = get_absolute_path(env_base_dir, env_config_dir) env_dir = get_absolute_path(env_base_dir, env_config_dir)
logger.debug(f"Envionment config dir: '{env_dir}'") logger.debug(f"Environment config dir: '{env_dir}'")
if env_dir is not None: if env_dir is not None:
config_dirs.append(env_dir.resolve()) config_dirs.append(env_dir.resolve())
config_dirs.append(Path(user_config_dir(self.APP_NAME))) config_dirs.append(Path(user_config_dir(cls.APP_NAME, cls.APP_AUTHOR)))
config_dirs.append(Path.cwd()) config_dirs.append(Path.cwd())
for cdir in config_dirs: for cdir in config_dirs:
cfile = cdir.joinpath(self.CONFIG_FILE_NAME) cfile = cdir.joinpath(cls.CONFIG_FILE_NAME)
if cfile.exists(): if cfile.exists():
logger.debug(f"Found config file: '{cfile}'") logger.debug(f"Found config file: '{cfile}'")
return cfile, True return cfile, True
return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False return config_dirs[0].joinpath(cls.CONFIG_FILE_NAME), False
def settings_from_config_file(self) -> tuple[SettingsEOS, Path]:
"""Load settings from the configuration file.
If the config file does not exist, it will be created.
Returns:
tuple of settings and path
settings (SettingsEOS): The settings defined by the EOS configuration file.
path (pathlib.Path): The path of the configuration file.
Raises:
ValueError: If the configuration file is invalid or incomplete.
"""
config_file, exists = self._get_config_file_path()
config_dir = config_file.parent
# Create config directory and copy default config if file does not exist
if not exists:
config_dir.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(self.config_default_file_path, config_file)
except Exception as exc:
logger.warning(f"Could not copy default config: {exc}. Using default config...")
config_file = self.config_default_file_path
config_dir = config_file.parent
# Load and validate the configuration file
with config_file.open("r", encoding=self.ENCODING) as f_in:
try:
json_str = f_in.read()
settings = SettingsEOS.model_validate_json(json_str)
except ValidationError as exc:
raise ValueError(f"Configuration '{config_file}' is incomplete or not valid: {exc}")
return settings, config_file
def from_config_file(self) -> tuple[SettingsEOS, Path]:
"""Load the configuration file settings for EOS.
Returns:
tuple of settings and path
settings (SettingsEOS): The settings defined by the EOS configuration file.
path (pathlib.Path): The path of the configuration file.
Raises:
ValueError: If the configuration file is invalid or incomplete.
"""
# Load settings from config file
ConfigEOS._file_settings, config_file = self.settings_from_config_file()
# Update configuration in memory
self.update()
# Everything worked, remember the values
self._config_folder_path = config_file.parent
self._config_file_path = config_file
return ConfigEOS._file_settings, config_file
def to_config_file(self) -> None: def to_config_file(self) -> None:
"""Saves the current configuration to the configuration file. """Saves the current configuration to the configuration file.
@@ -433,77 +515,24 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
Raises: Raises:
ValueError: If the configuration file path is not specified or can not be written to. ValueError: If the configuration file path is not specified or can not be written to.
""" """
if not self.config_file_path: if not self.general.config_file_path:
raise ValueError("Configuration file path unknown.") raise ValueError("Configuration file path unknown.")
with self.config_file_path.open("w", encoding=self.ENCODING) as f_out: with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out:
try: json_str = super().model_dump_json(indent=4)
json_str = super().to_json() f_out.write(json_str)
# Write to file
f_out.write(json_str)
# Also remember as actual settings
ConfigEOS._file_settings = SettingsEOS.model_validate_json(json_str)
except ValidationError as exc:
raise ValueError(f"Could not update '{self.config_file_path}': {exc}")
def _config_value(self, key: str) -> Any:
"""Retrieves the configuration value for a specific key, following a priority order.
Values are fetched in the following order:
1. Settings.
2. Environment variables.
3. EOS configuration file.
4. Current configuration.
5. Field default constants.
Args:
key (str): The configuration key to retrieve.
Returns:
Any: The configuration value, or None if not found.
"""
# Settings
if ConfigEOS._settings:
if (value := getattr(self.settings, key, None)) is not None:
return value
# Environment variables
if (value := os.getenv(key)) is not None:
try:
return float(value)
except ValueError:
return value
# EOS configuration file.
if self._file_settings:
if (value := getattr(self._file_settings, key, None)) is not None:
return value
# Current configuration - key is valid as called by update().
if (value := getattr(self, key, None)) is not None:
return value
# Field default constants
if (value := ConfigEOS.model_fields[key].default) is not None:
return value
logger.debug(f"Value for configuration key '{key}' not found or is {value}")
return None
def update(self) -> None: def update(self) -> None:
"""Updates all configuration fields. """Updates all configuration fields.
This method updates all configuration fields using the following order for value retrieval: This method updates all configuration fields using the following order for value retrieval:
1. Settings. 1. Current settings.
2. Environment variables. 2. Environment variables.
3. EOS configuration file. 3. EOS configuration file.
4. Current configuration. 4. Field default constants.
5. Field default constants.
The first non None value in priority order is taken. The first non None value in priority order is taken.
""" """
self._update_data_folder_path() self._setup(**self.model_dump())
for key in self.model_fields:
setattr(self, key, self._config_value(key))
def get_config() -> ConfigEOS: def get_config() -> ConfigEOS:

View File

@@ -1,13 +1,12 @@
"""Abstract and base classes for configuration.""" """Abstract and base classes for configuration."""
from typing import Any, ClassVar
from akkudoktoreos.core.pydantic import PydanticBaseModel from akkudoktoreos.core.pydantic import PydanticBaseModel
class SettingsBaseModel(PydanticBaseModel): class SettingsBaseModel(PydanticBaseModel):
"""Base model class for all settings configurations. """Base model class for all settings configurations."""
Note: # EOS configuration - set by ConfigEOS
Settings property names shall be disjunctive to all existing settings' property names. config: ClassVar[Any] = None
"""
pass

View File

@@ -1,32 +1,14 @@
"""Class for in-memory managing of cache files. """In-memory and file caching.
The `CacheFileStore` class is a singleton-based, thread-safe key-value store for managing Decorators and classes for caching results of computations,
temporary file objects, allowing the creation, retrieval, and management of cache files. both in memory (using an LRU cache) and in temporary files. It also includes
mechanisms for managing cache file expiration and retrieval.
Classes:
--------
- CacheFileStore: A thread-safe, singleton class for in-memory managing of file-like cache objects.
- CacheFileStoreMeta: Metaclass for enforcing the singleton behavior in `CacheFileStore`.
Example usage:
--------------
# CacheFileStore usage
>>> cache_store = CacheFileStore()
>>> cache_store.create('example_key')
>>> cache_file = cache_store.get('example_key')
>>> cache_file.write('Some data')
>>> cache_file.seek(0)
>>> print(cache_file.read()) # Output: 'Some data'
Notes:
------
- Cache files are automatically associated with the current date unless specified.
""" """
from __future__ import annotations import functools
import hashlib import hashlib
import inspect import inspect
import json
import os import os
import pickle import pickle
import tempfile import tempfile
@@ -35,8 +17,8 @@ from typing import (
IO, IO,
Any, Any,
Callable, Callable,
ClassVar,
Dict, Dict,
Generic,
List, List,
Literal, Literal,
Optional, Optional,
@@ -44,29 +26,223 @@ from typing import (
TypeVar, TypeVar,
) )
import cachebox
from loguru import logger
from pendulum import DateTime, Duration from pendulum import DateTime, Duration
from pydantic import BaseModel, ConfigDict, Field from pydantic import Field
from akkudoktoreos.core.coreabc import ConfigMixin from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
logger = get_logger(__name__) # ---------------------------------
# In-Memory Caching Functionality
# ---------------------------------
# Define a type variable for methods and functions
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
T = TypeVar("T") def cache_until_update_store_callback(event: int, key: Any, value: Any) -> None:
"""Calback function for CacheUntilUpdateStore."""
CacheUntilUpdateStore.last_event = event
CacheUntilUpdateStore.last_key = key
CacheUntilUpdateStore.last_value = value
if event == cachebox.EVENT_MISS:
CacheUntilUpdateStore.miss_count += 1
elif event == cachebox.EVENT_HIT:
CacheUntilUpdateStore.hit_count += 1
else:
# unreachable code
raise NotImplementedError
class CacheUntilUpdateStore(SingletonMixin):
"""Singleton-based in-memory LRU (Least Recently Used) cache.
This cache is shared across the application to store results of decorated
methods or functions until the next EMS (Energy Management System) update.
The cache uses an LRU eviction strategy, storing up to 100 items, with the oldest
items being evicted once the cache reaches its capacity.
"""
cache: ClassVar[cachebox.LRUCache] = cachebox.LRUCache(maxsize=100, iterable=None, capacity=100)
last_event: ClassVar[Optional[int]] = None
last_key: ClassVar[Any] = None
last_value: ClassVar[Any] = None
hit_count: ClassVar[int] = 0
miss_count: ClassVar[int] = 0
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initializes the `CacheUntilUpdateStore` instance with default parameters.
The cache uses an LRU eviction strategy with a maximum size of 100 items.
This cache is a singleton, meaning only one instance will exist throughout
the application lifecycle.
Example:
>>> cache = CacheUntilUpdateStore()
"""
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def __getattr__(self, name: str) -> Any:
"""Propagates method calls to the cache object.
This method allows you to call methods on the underlying cache object,
and it will delegate the call to the cache's corresponding method.
Args:
name (str): The name of the method being called.
Returns:
Callable: A method bound to the cache object.
Raises:
AttributeError: If the cache object does not have the requested method.
Example:
>>> result = cache.get("key")
"""
# This will return a method of the target cache, or raise an AttributeError
target_attr = getattr(self.cache, name)
if callable(target_attr):
return target_attr
else:
return target_attr
def __getitem__(self, key: Any) -> Any:
"""Retrieves an item from the cache by its key.
Args:
key (Any): The key used for subscripting to retrieve an item.
Returns:
Any: The value corresponding to the key in the cache.
Raises:
KeyError: If the key does not exist in the cache.
Example:
>>> value = cache["user_data"]
"""
return CacheUntilUpdateStore.cache[key]
def __setitem__(self, key: Any, value: Any) -> None:
"""Stores an item in the cache.
Args:
key (Any): The key used to store the item in the cache.
value (Any): The value to store.
Example:
>>> cache["user_data"] = {"name": "Alice", "age": 30}
"""
CacheUntilUpdateStore.cache[key] = value
def __len__(self) -> int:
"""Returns the number of items in the cache."""
return len(CacheUntilUpdateStore.cache)
def __repr__(self) -> str:
"""Provides a string representation of the CacheUntilUpdateStore object."""
return repr(CacheUntilUpdateStore.cache)
def clear(self) -> None:
"""Clears the cache, removing all stored items.
This method propagates the `clear` method call to the underlying cache object,
ensuring that the cache is emptied when necessary (e.g., at the end of the energy
management system run).
Example:
>>> cache.clear()
"""
if hasattr(self.cache, "clear") and callable(getattr(self.cache, "clear")):
CacheUntilUpdateStore.cache.clear()
CacheUntilUpdateStore.last_event = None
CacheUntilUpdateStore.last_key = None
CacheUntilUpdateStore.last_value = None
CacheUntilUpdateStore.miss_count = 0
CacheUntilUpdateStore.hit_count = 0
else:
raise AttributeError(f"'{self.cache.__class__.__name__}' object has no method 'clear'")
def cachemethod_until_update(method: TCallable) -> TCallable:
"""Decorator for in memory caching the result of an instance method.
This decorator caches the method's result in `CacheUntilUpdateStore`, ensuring
that subsequent calls with the same arguments return the cached result until the
next EMS update cycle.
Args:
method (Callable): The instance method to be decorated.
Returns:
Callable: The wrapped method with caching functionality.
Example:
>>> class MyClass:
>>> @cachemethod_until_update
>>> def expensive_method(self, param: str) -> str:
>>> # Perform expensive computation
>>> return f"Computed {param}"
"""
@cachebox.cachedmethod(
cache=CacheUntilUpdateStore().cache, callback=cache_until_update_store_callback
)
@functools.wraps(method)
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
result = method(self, *args, **kwargs)
return result
return wrapper
def cache_until_update(func: TCallable) -> TCallable:
"""Decorator for in memory caching the result of a standalone function.
This decorator caches the function's result in `CacheUntilUpdateStore`, ensuring
that subsequent calls with the same arguments return the cached result until the
next EMS update cycle.
Args:
func (Callable): The function to be decorated.
Returns:
Callable: The wrapped function with caching functionality.
Example:
>>> @cache_until_next_update
>>> def expensive_function(param: str) -> str:
>>> # Perform expensive computation
>>> return f"Computed {param}"
"""
@cachebox.cached(
cache=CacheUntilUpdateStore().cache, callback=cache_until_update_store_callback
)
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
result = func(*args, **kwargs)
return result
return wrapper
# ---------------------------------
# Cache File Management
# ---------------------------------
Param = ParamSpec("Param") Param = ParamSpec("Param")
RetType = TypeVar("RetType") RetType = TypeVar("RetType")
class CacheFileRecord(BaseModel): class CacheFileRecord(PydanticBaseModel):
# Enable custom serialization globally in config
model_config = ConfigDict(
arbitrary_types_allowed=True,
use_enum_values=True,
validate_assignment=True,
)
cache_file: Any = Field(..., description="File descriptor of the cache file.") cache_file: Any = Field(..., description="File descriptor of the cache file.")
until_datetime: DateTime = Field(..., description="Datetime until the cache file is valid.") until_datetime: DateTime = Field(..., description="Datetime until the cache file is valid.")
ttl_duration: Optional[Duration] = Field( ttl_duration: Optional[Duration] = Field(
@@ -74,24 +250,7 @@ class CacheFileRecord(BaseModel):
) )
class CacheFileStoreMeta(type, Generic[T]): class CacheFileStore(ConfigMixin, SingletonMixin):
"""A thread-safe implementation of CacheFileStore."""
_instances: dict[CacheFileStoreMeta[T], T] = {}
_lock: threading.Lock = threading.Lock()
"""Lock object to synchronize threads on first access to CacheFileStore."""
def __call__(cls) -> T:
"""Return CacheFileStore instance."""
with cls._lock:
if cls not in cls._instances:
instance = super().__call__()
cls._instances[cls] = instance
return cls._instances[cls]
class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
"""A key-value store that manages file-like tempfile objects to be used as cache files. """A key-value store that manages file-like tempfile objects to be used as cache files.
Cache files are associated with a date. If no date is specified, the cache files are Cache files are associated with a date. If no date is specified, the cache files are
@@ -105,7 +264,7 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
store (dict): A dictionary that holds the in-memory cache file objects store (dict): A dictionary that holds the in-memory cache file objects
with their associated keys and dates. with their associated keys and dates.
Example usage: Example:
>>> cache_store = CacheFileStore() >>> cache_store = CacheFileStore()
>>> cache_store.create('example_file') >>> cache_store.create('example_file')
>>> cache_file = cache_store.get('example_file') >>> cache_file = cache_store.get('example_file')
@@ -114,14 +273,18 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
>>> print(cache_file.read()) # Output: 'Some data' >>> print(cache_file.read()) # Output: 'Some data'
""" """
def __init__(self) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initializes the CacheFileStore instance. """Initializes the CacheFileStore instance.
This constructor sets up an empty key-value store (a dictionary) where each key This constructor sets up an empty key-value store (a dictionary) where each key
corresponds to a cache file that is associated with a given key and an optional date. corresponds to a cache file that is associated with a given key and an optional date.
""" """
if hasattr(self, "_initialized"):
return
self._store: Dict[str, CacheFileRecord] = {} self._store: Dict[str, CacheFileRecord] = {}
self._store_lock = threading.Lock() self._store_lock = threading.RLock()
self._store_file = self.config.cache.path().joinpath("cachefilestore.json")
super().__init__(*args, **kwargs)
def _until_datetime_by_options( def _until_datetime_by_options(
self, self,
@@ -329,9 +492,9 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
# File already available # File already available
cache_file_obj = cache_item.cache_file cache_file_obj = cache_item.cache_file
else: else:
self.config.data_cache_path.mkdir(parents=True, exist_ok=True) self.config.cache.path().mkdir(parents=True, exist_ok=True)
cache_file_obj = tempfile.NamedTemporaryFile( cache_file_obj = tempfile.NamedTemporaryFile(
mode=mode, delete=delete, suffix=suffix, dir=self.config.data_cache_path mode=mode, delete=delete, suffix=suffix, dir=self.config.cache.path()
) )
self._store[cache_file_key] = CacheFileRecord( self._store[cache_file_key] = CacheFileRecord(
cache_file=cache_file_obj, cache_file=cache_file_obj,
@@ -502,7 +665,7 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
def clear( def clear(
self, self,
clear_all: bool = False, clear_all: Optional[bool] = None,
before_datetime: Optional[Any] = None, before_datetime: Optional[Any] = None,
) -> None: ) -> None:
"""Deletes all cache files or those expiring before `before_datetime`. """Deletes all cache files or those expiring before `before_datetime`.
@@ -516,8 +679,6 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
Raises: Raises:
OSError: If there's an error during file deletion. OSError: If there's an error during file deletion.
""" """
delete_keys = [] # List of keys to delete, prevent deleting when traversing the store
# Some weired logic to prevent calling to_datetime on clear_all. # Some weired logic to prevent calling to_datetime on clear_all.
# Clear_all may be set on __del__. At this time some info for to_datetime will # Clear_all may be set on __del__. At this time some info for to_datetime will
# not be available anymore. # not be available anymore.
@@ -528,6 +689,8 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
before_datetime = to_datetime(before_datetime) before_datetime = to_datetime(before_datetime)
with self._store_lock: # Synchronize access to _store with self._store_lock: # Synchronize access to _store
delete_keys = [] # List of keys to delete, prevent deleting when traversing the store
for cache_file_key, cache_item in self._store.items(): for cache_file_key, cache_item in self._store.items():
# Some weired logic to prevent calling to_datetime on clear_all. # Some weired logic to prevent calling to_datetime on clear_all.
# Clear_all may be set on __del__. At this time some info for to_datetime will # Clear_all may be set on __del__. At this time some info for to_datetime will
@@ -566,6 +729,89 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
for delete_key in delete_keys: for delete_key in delete_keys:
del self._store[delete_key] del self._store[delete_key]
def current_store(self) -> dict:
"""Current state of the store.
Returns:
data (dict): current cache management data.
"""
with self._store_lock:
store_current = {}
for key, record in self._store.items():
ttl_duration = record.ttl_duration
if ttl_duration:
ttl_duration = ttl_duration.total_seconds()
store_current[key] = {
# Convert file-like objects to file paths for serialization
"cache_file": self._get_file_path(record.cache_file),
"mode": record.cache_file.mode,
"until_datetime": to_datetime(record.until_datetime, as_string=True),
"ttl_duration": ttl_duration,
}
return store_current
def save_store(self) -> dict:
"""Saves the current state of the store to a file.
Returns:
data (dict): cache management data that was saved.
"""
with self._store_lock:
self._store_file.parent.mkdir(parents=True, exist_ok=True)
store_to_save = self.current_store()
with self._store_file.open("w", encoding="utf-8", newline="\n") as f:
try:
json.dump(store_to_save, f, indent=4)
except Exception as e:
logger.error(f"Error saving cache file store: {e}")
return store_to_save
def load_store(self) -> dict:
"""Loads the state of the store from a file.
Returns:
data (dict): cache management data that was loaded.
"""
with self._store_lock:
store_loaded = {}
if self._store_file.exists():
with self._store_file.open("r", encoding="utf-8", newline=None) as f:
try:
store_to_load = json.load(f)
except Exception as e:
logger.error(
f"Error loading cache file store: {e}\n"
+ f"Deleting the store file {self._store_file}."
)
self._store_file.unlink()
return {}
for key, record in store_to_load.items():
if record is None:
continue
if key in self._store.keys():
# Already available - do not overwrite by record from file
continue
try:
cache_file_obj = open(
record["cache_file"], "rb+" if "b" in record["mode"] else "r+"
)
except Exception as e:
cache_file_record = record["cache_file"]
logger.warning(f"Can not open cache file '{cache_file_record}': {e}")
continue
ttl_duration = record["ttl_duration"]
if ttl_duration:
ttl_duration = to_duration(float(record["ttl_duration"]))
self._store[key] = CacheFileRecord(
cache_file=cache_file_obj,
until_datetime=record["until_datetime"],
ttl_duration=ttl_duration,
)
cache_file_obj.seek(0)
# Remember newly loaded
store_loaded[key] = record
return store_loaded
def cache_in_file( def cache_in_file(
ignore_params: List[str] = [], ignore_params: List[str] = [],
@@ -707,7 +953,7 @@ def cache_in_file(
logger.debug("Used cache file for function: " + func.__name__) logger.debug("Used cache file for function: " + func.__name__)
cache_file.seek(0) cache_file.seek(0)
if "b" in mode: if "b" in mode:
result = pickle.load(cache_file) result = pickle.load(cache_file) # noqa: S301
else: else:
result = cache_file.read() result = cache_file.read()
except Exception as e: except Exception as e:

View File

@@ -0,0 +1,32 @@
"""Settings for caching.
Kept in an extra module to avoid cyclic dependencies on package import.
"""
from pathlib import Path
from typing import Optional
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
class CacheCommonSettings(SettingsBaseModel):
"""Cache Configuration."""
subpath: Optional[Path] = Field(
default="cache", description="Sub-path for the EOS cache data directory."
)
cleanup_interval: float = Field(
default=5 * 60, description="Intervall in seconds for EOS file cache cleanup."
)
# Do not make this a pydantic computed field. The pydantic model must be fully initialized
# to have access to config.general, which may not be the case if it is a computed field.
def path(self) -> Optional[Path]:
"""Compute cache path based on general.data_folder_path."""
data_cache_path = self.config.general.data_folder_path
if data_cache_path is None or self.subpath is None:
return None
return data_cache_path.joinpath(self.subpath)

View File

@@ -13,13 +13,10 @@ Classes:
import threading import threading
from typing import Any, ClassVar, Dict, Optional, Type from typing import Any, ClassVar, Dict, Optional, Type
from loguru import logger
from pendulum import DateTime from pendulum import DateTime
from pydantic import computed_field from pydantic import computed_field
from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
config_eos: Any = None config_eos: Any = None
measurement_eos: Any = None measurement_eos: Any = None
prediction_eos: Any = None prediction_eos: Any = None
@@ -265,6 +262,14 @@ class SingletonMixin:
class MySingletonModel(SingletonMixin, PydanticBaseModel): class MySingletonModel(SingletonMixin, PydanticBaseModel):
name: str name: str
# implement __init__ to avoid re-initialization of parent classes:
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
# Your initialisation here
...
super().__init__(*args, **kwargs)
instance1 = MySingletonModel(name="Instance 1") instance1 = MySingletonModel(name="Instance 1")
instance2 = MySingletonModel(name="Instance 2") instance2 = MySingletonModel(name="Instance 2")

View File

@@ -19,6 +19,7 @@ from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union, over
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import pendulum import pendulum
from loguru import logger
from numpydantic import NDArray, Shape from numpydantic import NDArray, Shape
from pendulum import DateTime, Duration from pendulum import DateTime, Duration
from pydantic import ( from pydantic import (
@@ -31,7 +32,6 @@ from pydantic import (
) )
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin, StartMixin from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin, StartMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ( from akkudoktoreos.core.pydantic import (
PydanticBaseModel, PydanticBaseModel,
PydanticDateTimeData, PydanticDateTimeData,
@@ -39,8 +39,6 @@ from akkudoktoreos.core.pydantic import (
) )
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
logger = get_logger(__name__)
class DataBase(ConfigMixin, StartMixin, PydanticBaseModel): class DataBase(ConfigMixin, StartMixin, PydanticBaseModel):
"""Base class for handling generic data. """Base class for handling generic data.
@@ -811,7 +809,8 @@ class DataSequence(DataBase, MutableSequence):
dates, values = self.key_to_lists( dates, values = self.key_to_lists(
key=key, start_datetime=start_datetime, end_datetime=end_datetime, dropna=dropna key=key, start_datetime=start_datetime, end_datetime=end_datetime, dropna=dropna
) )
return pd.Series(data=values, index=pd.DatetimeIndex(dates), name=key) series = pd.Series(data=values, index=pd.DatetimeIndex(dates), name=key)
return series
def key_from_series(self, key: str, series: pd.Series) -> None: def key_from_series(self, key: str, series: pd.Series) -> None:
"""Update the DataSequence from a Pandas Series. """Update the DataSequence from a Pandas Series.
@@ -953,6 +952,44 @@ class DataSequence(DataBase, MutableSequence):
array = resampled.values array = resampled.values
return array return array
def to_dataframe(
self,
start_datetime: Optional[DateTime] = None,
end_datetime: Optional[DateTime] = None,
) -> pd.DataFrame:
"""Converts the sequence of DataRecord instances into a Pandas DataFrame.
Args:
start_datetime (Optional[datetime]): The lower bound for filtering (inclusive).
Defaults to the earliest possible datetime if None.
end_datetime (Optional[datetime]): The upper bound for filtering (exclusive).
Defaults to the latest possible datetime if None.
Returns:
pd.DataFrame: A DataFrame containing the filtered data from all records.
"""
if not self.records:
return pd.DataFrame() # Return empty DataFrame if no records exist
# Use filter_by_datetime to get filtered records
filtered_records = self.filter_by_datetime(start_datetime, end_datetime)
# Convert filtered records to a dictionary list
data = [record.model_dump() for record in filtered_records]
# Convert to DataFrame
df = pd.DataFrame(data)
if df.empty:
return df
# Ensure `date_time` column exists and use it for the index
if not "date_time" in df.columns:
error_msg = f"Cannot create dataframe: no `date_time` column in `{df}`."
logger.error(error_msg)
raise TypeError(error_msg)
df.index = pd.DatetimeIndex(df["date_time"])
return df
def sort_by_datetime(self, reverse: bool = False) -> None: def sort_by_datetime(self, reverse: bool = False) -> None:
"""Sort the DataRecords in the sequence by their date_time attribute. """Sort the DataRecords in the sequence by their date_time attribute.
@@ -1110,7 +1147,7 @@ class DataProvider(SingletonMixin, DataSequence):
To be implemented by derived classes. To be implemented by derived classes.
""" """
return self.provider_id() == self.config.abstract_provider raise NotImplementedError()
@abstractmethod @abstractmethod
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
@@ -1121,6 +1158,11 @@ class DataProvider(SingletonMixin, DataSequence):
""" """
pass pass
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def update_data( def update_data(
self, self,
force_enable: Optional[bool] = False, force_enable: Optional[bool] = False,
@@ -1224,14 +1266,14 @@ class DataImportMixin:
# We jump back by 1 hour # We jump back by 1 hour
# Repeat the value(s) (reuse value index) # Repeat the value(s) (reuse value index)
for i in range(interval_steps_per_hour): for i in range(interval_steps_per_hour):
logger.debug(f"{i+1}: Repeat at {next_time} with index {value_index}") logger.debug(f"{i + 1}: Repeat at {next_time} with index {value_index}")
timestamps_with_indices.append((next_time, value_index)) timestamps_with_indices.append((next_time, value_index))
next_time = next_time.add(seconds=interval.total_seconds()) next_time = next_time.add(seconds=interval.total_seconds())
else: else:
# We jump forward by 1 hour # We jump forward by 1 hour
# Drop the value(s) # Drop the value(s)
logger.debug( logger.debug(
f"{i+1}: Skip {interval_steps_per_hour} at {next_time} with index {value_index}" f"{i + 1}: Skip {interval_steps_per_hour} at {next_time} with index {value_index}"
) )
value_index += interval_steps_per_hour value_index += interval_steps_per_hour
@@ -1460,7 +1502,7 @@ class DataImportMixin:
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n" error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
logger.debug(f"PydanticDateTimeDataFrame import: {error_msg}") logger.debug(f"PydanticDateTimeDataFrame import: {error_msg}")
# Try dictionary with special keys start_datetime and intervall # Try dictionary with special keys start_datetime and interval
try: try:
import_data = PydanticDateTimeData.model_validate_json(json_str) import_data = PydanticDateTimeData.model_validate_json(json_str)
self.import_from_dict(import_data.to_dict()) self.import_from_dict(import_data.to_dict())
@@ -1520,7 +1562,7 @@ class DataImportMixin:
and `key_prefix = "load"`, only the "load_mean" key will be processed even though and `key_prefix = "load"`, only the "load_mean" key will be processed even though
both keys are in the record. both keys are in the record.
""" """
with import_file_path.open("r") as import_file: with import_file_path.open("r", encoding="utf-8", newline=None) as import_file:
import_str = import_file.read() import_str = import_file.read()
self.import_from_json( self.import_from_json(
import_str, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval import_str, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval
@@ -1595,6 +1637,11 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
) )
return list(key_set) return list(key_set)
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def __getitem__(self, key: str) -> pd.Series: def __getitem__(self, key: str) -> pd.Series:
"""Retrieve a Pandas Series for a specified key from the data in each DataProvider. """Retrieve a Pandas Series for a specified key from the data in each DataProvider.
@@ -1797,6 +1844,88 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
return array return array
def keys_to_dataframe(
self,
keys: list[str],
start_datetime: Optional[DateTime] = None,
end_datetime: Optional[DateTime] = None,
interval: Optional[Any] = None, # Duration assumed
fill_method: Optional[str] = None,
) -> pd.DataFrame:
"""Retrieve a dataframe indexed by fixed time intervals for specified keys from the data in each DataProvider.
Generates a pandas DataFrame using the NumPy arrays for each specified key, ensuring a common time index..
Args:
keys (list[str]): A list of field names to retrieve.
start_datetime (datetime, optional): Start date for filtering records (inclusive).
end_datetime (datetime, optional): End date for filtering records (exclusive).
interval (duration, optional): The fixed time interval. Defaults to 1 hour.
fill_method (str, optional): Method to handle missing values during resampling.
- 'linear': Linearly interpolate missing values (for numeric data only).
- 'ffill': Forward fill missing values.
- 'bfill': Backward fill missing values.
- 'none': Defaults to 'linear' for numeric values, otherwise 'ffill'.
Returns:
pd.DataFrame: A DataFrame where each column represents a key's array with a common time index.
Raises:
KeyError: If no valid data is found for any of the requested keys.
ValueError: If any retrieved array has a different time index than the first one.
"""
# Ensure datetime objects are normalized
start_datetime = to_datetime(start_datetime, to_maxtime=False) if start_datetime else None
end_datetime = to_datetime(end_datetime, to_maxtime=False) if end_datetime else None
if interval is None:
interval = to_duration("1 hour")
if start_datetime is None:
# Take earliest datetime of all providers that are enabled
for provider in self.enabled_providers:
if start_datetime is None:
start_datetime = provider.min_datetime
elif (
provider.min_datetime
and compare_datetimes(provider.min_datetime, start_datetime).lt
):
start_datetime = provider.min_datetime
if end_datetime is None:
# Take latest datetime of all providers that are enabled
for provider in self.enabled_providers:
if end_datetime is None:
end_datetime = provider.max_datetime
elif (
provider.max_datetime
and compare_datetimes(provider.max_datetime, end_datetime).gt
):
end_datetime = provider.min_datetime
if end_datetime:
end_datetime.add(seconds=1)
# Create a DatetimeIndex based on start, end, and interval
reference_index = pd.date_range(
start=start_datetime, end=end_datetime, freq=interval, inclusive="left"
)
data = {}
for key in keys:
try:
array = self.key_to_array(key, start_datetime, end_datetime, interval, fill_method)
if len(array) != len(reference_index):
raise ValueError(
f"Array length mismatch for key '{key}' (expected {len(reference_index)}, got {len(array)})"
)
data[key] = array
except KeyError as e:
raise KeyError(f"Failed to retrieve data for key '{key}': {e}")
if not data:
raise KeyError(f"No valid data found for the requested keys {keys}.")
return pd.DataFrame(data, index=reference_index)
def provider_by_id(self, provider_id: str) -> DataProvider: def provider_by_id(self, provider_id: str) -> DataProvider:
"""Retrieves a data provider by its unique identifier. """Retrieves a data provider by its unique identifier.

View File

@@ -0,0 +1,44 @@
from collections.abc import Callable
from typing import Any, Optional
class classproperty:
"""A decorator to define a read-only property at the class level.
This class replaces the built-in `property` which is no longer available in
combination with @classmethod since Python 3.13 to allow a method to be
accessed as a property on the class itself, rather than an instance. This
is useful when you want a property-like syntax for methods that depend on
the class rather than any instance of the class.
Example:
class MyClass:
_value = 42
@classproperty
def value(cls):
return cls._value
print(MyClass.value) # Outputs: 42
Methods:
__get__: Retrieves the value of the class property by calling the
decorated method on the class.
Parameters:
fget (Callable[[Any], Any]): A method that takes the class as an
argument and returns a value.
Raises:
RuntimeError: If `fget` is not defined when `__get__` is called.
"""
def __init__(self, fget: Callable[[Any], Any]) -> None:
self.fget = fget
def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any:
if owner_cls is None:
return self
if self.fget is None:
raise RuntimeError("'fget' not defined when `__get__` is called")
return self.fget(owner_cls)

View File

@@ -1,24 +1,24 @@
import traceback
from typing import Any, ClassVar, Optional from typing import Any, ClassVar, Optional
import numpy as np import numpy as np
from loguru import logger
from numpydantic import NDArray, Shape from numpydantic import NDArray, Shape
from pendulum import DateTime from pendulum import DateTime
from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator
from typing_extensions import Self from typing_extensions import Self
from akkudoktoreos.core.cache import CacheUntilUpdateStore
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel, PydanticBaseModel from akkudoktoreos.core.pydantic import ParametersBaseModel, PydanticBaseModel
from akkudoktoreos.devices.battery import Battery from akkudoktoreos.devices.battery import Battery
from akkudoktoreos.devices.generic import HomeAppliance from akkudoktoreos.devices.generic import HomeAppliance
from akkudoktoreos.devices.inverter import Inverter from akkudoktoreos.devices.inverter import Inverter
from akkudoktoreos.utils.datetimeutil import to_datetime from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
from akkudoktoreos.utils.utils import NumpyEncoder from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
class EnergyManagementParameters(ParametersBaseModel):
class EnergieManagementSystemParameters(ParametersBaseModel):
pv_prognose_wh: list[float] = Field( pv_prognose_wh: list[float] = Field(
description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals." description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
) )
@@ -107,7 +107,7 @@ class SimulationResult(ParametersBaseModel):
return NumpyEncoder.convert_numpy(field)[0] return NumpyEncoder.convert_numpy(field)[0]
class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel): class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel):
# Disable validation on assignment to speed up simulation runs. # Disable validation on assignment to speed up simulation runs.
model_config = ConfigDict( model_config = ConfigDict(
validate_assignment=False, validate_assignment=False,
@@ -116,16 +116,33 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
# Start datetime. # Start datetime.
_start_datetime: ClassVar[Optional[DateTime]] = None _start_datetime: ClassVar[Optional[DateTime]] = None
# last run datetime. Used by energy management task
_last_datetime: ClassVar[Optional[DateTime]] = None
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def start_datetime(self) -> DateTime: def start_datetime(self) -> DateTime:
"""The starting datetime of the current or latest energy management.""" """The starting datetime of the current or latest energy management."""
if EnergieManagementSystem._start_datetime is None: if EnergyManagement._start_datetime is None:
EnergieManagementSystem.set_start_datetime() EnergyManagement.set_start_datetime()
return EnergieManagementSystem._start_datetime return EnergyManagement._start_datetime
@classmethod @classmethod
def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime: def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime:
"""Set the start datetime for the next energy management cycle.
If no datetime is provided, the current datetime is used.
The start datetime is always rounded down to the nearest hour
(i.e., setting minutes, seconds, and microseconds to zero).
Args:
start_datetime (Optional[DateTime]): The datetime to set as the start.
If None, the current datetime is used.
Returns:
DateTime: The adjusted start datetime.
"""
if start_datetime is None: if start_datetime is None:
start_datetime = to_datetime() start_datetime = to_datetime()
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0) cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
@@ -169,9 +186,14 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD") dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD") ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def set_parameters( def set_parameters(
self, self,
parameters: EnergieManagementSystemParameters, parameters: EnergyManagementParameters,
ev: Optional[Battery] = None, ev: Optional[Battery] = None,
home_appliance: Optional[HomeAppliance] = None, home_appliance: Optional[HomeAppliance] = None,
inverter: Optional[Inverter] = None, inverter: Optional[Inverter] = None,
@@ -193,9 +215,9 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
self.ev = ev self.ev = ev
self.home_appliance = home_appliance self.home_appliance = home_appliance
self.inverter = inverter self.inverter = inverter
self.ac_charge_hours = np.full(self.config.prediction_hours, 0.0) self.ac_charge_hours = np.full(self.config.prediction.hours, 0.0)
self.dc_charge_hours = np.full(self.config.prediction_hours, 1.0) self.dc_charge_hours = np.full(self.config.prediction.hours, 1.0)
self.ev_charge_hours = np.full(self.config.prediction_hours, 0.0) self.ev_charge_hours = np.full(self.config.prediction.hours, 0.0)
def set_akku_discharge_hours(self, ds: np.ndarray) -> None: def set_akku_discharge_hours(self, ds: np.ndarray) -> None:
if self.battery: if self.battery:
@@ -238,26 +260,88 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
is mostly relevant to prediction providers. is mostly relevant to prediction providers.
force_update (bool, optional): If True, forces to update the data even if still cached. force_update (bool, optional): If True, forces to update the data even if still cached.
""" """
# Throw away any cached results of the last run.
CacheUntilUpdateStore().clear()
self.set_start_hour(start_hour=start_hour) self.set_start_hour(start_hour=start_hour)
self.config.update()
# Check for run definitions # Check for run definitions
if self.start_datetime is None: if self.start_datetime is None:
error_msg = "Start datetime unknown." error_msg = "Start datetime unknown."
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
if self.config.prediction_hours is None: if self.config.prediction.hours is None:
error_msg = "Prediction hours unknown." error_msg = "Prediction hours unknown."
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
if self.config.optimisation_hours is None: if self.config.optimization.hours is None:
error_msg = "Optimisation hours unknown." error_msg = "Optimization hours unknown."
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
self.prediction.update_data(force_enable=force_enable, force_update=force_update) self.prediction.update_data(force_enable=force_enable, force_update=force_update)
# TODO: Create optimisation problem that calls into devices.update_data() for simulations. # TODO: Create optimisation problem that calls into devices.update_data() for simulations.
logger.info("Energy management run (crippled version - prediction update only)")
def manage_energy(self) -> None:
"""Repeating task for managing energy.
This task should be executed by the server regularly (e.g., every 10 seconds)
to ensure proper energy management. Configuration changes to the energy management interval
will only take effect if this task is executed.
- Initializes and runs the energy management for the first time if it has never been run
before.
- If the energy management interval is not configured or invalid (NaN), the task will not
trigger any repeated energy management runs.
- Compares the current time with the last run time and runs the energy management if the
interval has elapsed.
- Logs any exceptions that occur during the initialization or execution of the energy
management.
Note: The task maintains the interval even if some intervals are missed.
"""
current_datetime = to_datetime()
interval = self.config.ems.interval # interval maybe changed in between
if EnergyManagement._last_datetime is None:
# Never run before
try:
# Remember energy run datetime.
EnergyManagement._last_datetime = current_datetime
# Try to run a first energy management. May fail due to config incomplete.
self.run()
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
message = f"EOS init: {e}\n{trace}"
logger.error(message)
return
if interval is None or interval == float("nan"):
# No Repetition
return
if (
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
< interval
):
# Wait for next run
return
try:
self.run()
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
message = f"EOS run: {e}\n{trace}"
logger.error(message)
# Remember the energy management run - keep on interval even if we missed some intervals
while (
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
>= interval
):
EnergyManagement._last_datetime = EnergyManagement._last_datetime.add(seconds=interval)
def set_start_hour(self, start_hour: Optional[int] = None) -> None: def set_start_hour(self, start_hour: Optional[int] = None) -> None:
"""Sets start datetime to given hour. """Sets start datetime to given hour.
@@ -310,7 +394,8 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
# Fetch objects # Fetch objects
battery = self.battery battery = self.battery
assert battery # to please mypy if battery is None:
raise ValueError(f"battery not set: {battery}")
ev = self.ev ev = self.ev
home_appliance = self.home_appliance home_appliance = self.home_appliance
inverter = self.inverter inverter = self.inverter
@@ -435,9 +520,9 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
# Initialize the Energy Management System, it is a singleton. # Initialize the Energy Management System, it is a singleton.
ems = EnergieManagementSystem() ems = EnergyManagement()
def get_ems() -> EnergieManagementSystem: def get_ems() -> EnergyManagement:
"""Gets the EOS Energy Management System.""" """Gets the EOS Energy Management System."""
return ems return ems

View File

@@ -0,0 +1,26 @@
"""Settings for energy management.
Kept in an extra module to avoid cyclic dependencies on package import.
"""
from typing import Optional
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
class EnergyManagementCommonSettings(SettingsBaseModel):
"""Energy Management Configuration."""
startup_delay: float = Field(
default=5,
ge=1,
description="Startup delay in seconds for EOS energy management runs.",
)
interval: Optional[float] = Field(
default=None,
description="Intervall in seconds between EOS energy management runs.",
examples=["300"],
)

View File

@@ -1,20 +1,3 @@
"""Abstract and base classes for logging.""" """Abstract and base classes for logging."""
import logging LOGGING_LEVELS: list[str] = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
def logging_str_to_level(level_str: str) -> int:
"""Convert log level string to logging level."""
if level_str == "DEBUG":
level = logging.DEBUG
elif level_str == "INFO":
level = logging.INFO
elif level_str == "WARNING":
level = logging.WARNING
elif level_str == "CRITICAL":
level = logging.CRITICAL
elif level_str == "ERROR":
level = logging.ERROR
else:
raise ValueError(f"Unknown loggin level: {level_str}")
return level

View File

@@ -1,91 +1,241 @@
"""Utility functions for handling logging tasks. """Utility for configuring Loguru loggers."""
Functions:
----------
- get_logger: Creates and configures a logger with console and optional rotating file logging.
Example usage:
--------------
# Logger setup
>>> logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
>>> logger.info("Logging initialized.")
Notes:
------
- The logger supports rotating log files to prevent excessive log file size.
"""
import json
import logging as pylogging import logging as pylogging
import os import os
from logging.handlers import RotatingFileHandler import re
from typing import Optional import sys
from pathlib import Path
from types import FrameType
from typing import Any, List, Optional
from akkudoktoreos.core.logabc import logging_str_to_level import pendulum
from loguru import logger
from akkudoktoreos.core.logabc import LOGGING_LEVELS
def get_logger( class InterceptHandler(pylogging.Handler):
name: str, """A logging handler that redirects standard Python logging messages to Loguru.
log_file: Optional[str] = None,
logging_level: Optional[str] = None,
max_bytes: int = 5000000,
backup_count: int = 5,
) -> pylogging.Logger:
"""Creates and configures a logger with a given name.
The logger supports logging to both the console and an optional log file. File logging is This handler ensures consistency between the `logging` module and Loguru by intercepting
handled by a rotating file handler to prevent excessive log file size. logs sent to the standard logging system and re-emitting them through Loguru with proper
formatting and context (including exception info and call depth).
Attributes:
loglevel_mapping (dict): Mapping from standard logging levels to Loguru level names.
"""
loglevel_mapping: dict[int, str] = {
50: "CRITICAL",
40: "ERROR",
30: "WARNING",
20: "INFO",
10: "DEBUG",
5: "TRACE",
0: "NOTSET",
}
def emit(self, record: pylogging.LogRecord) -> None:
"""Emits a logging record by forwarding it to Loguru with preserved metadata.
Args:
record (logging.LogRecord): A record object containing log message and metadata.
"""
try:
level = logger.level(record.levelname).name
except AttributeError:
level = self.loglevel_mapping.get(record.levelno, "INFO")
frame: Optional[FrameType] = pylogging.currentframe()
depth: int = 2
while frame and frame.f_code.co_filename == pylogging.__file__:
frame = frame.f_back
depth += 1
log = logger.bind(request_id="app")
log.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
console_handler_id = None
file_handler_id = None
def track_logging_config(config_eos: Any, path: str, old_value: Any, value: Any) -> None:
"""Track logging config changes."""
global console_handler_id, file_handler_id
if not path.startswith("logging"):
raise ValueError(f"Logging shall not track '{path}'")
if not config_eos.logging.console_level:
# No value given - check environment value - may also be None
config_eos.logging.console_level = os.getenv("EOS_LOGGING__LEVEL")
if not config_eos.logging.file_level:
# No value given - check environment value - may also be None
config_eos.logging.file_level = os.getenv("EOS_LOGGING__LEVEL")
# Remove handlers
if console_handler_id:
try:
logger.remove(console_handler_id)
except Exception as e:
logger.debug("Exception on logger.remove: {}", e, exc_info=True)
console_handler_id = None
if file_handler_id:
try:
logger.remove(file_handler_id)
except Exception as e:
logger.debug("Exception on logger.remove: {}", e, exc_info=True)
file_handler_id = None
# Create handlers with new configuration
# Always add console handler
if config_eos.logging.console_level not in LOGGING_LEVELS:
logger.error(
f"Invalid console log level '{config_eos.logging.console_level} - forced to INFO'."
)
config_eos.logging.console_level = "INFO"
console_handler_id = logger.add(
sys.stderr,
enqueue=True,
backtrace=True,
level=config_eos.logging.console_level,
# format=_console_format
)
# Add file handler
if config_eos.logging.file_level and config_eos.logging.file_path:
if config_eos.logging.file_level not in LOGGING_LEVELS:
logger.error(
f"Invalid file log level '{config_eos.logging.console_level}' - forced to INFO."
)
config_eos.logging.file_level = "INFO"
file_handler_id = logger.add(
sink=config_eos.logging.file_path,
rotation="100 MB",
retention="3 days",
enqueue=True,
backtrace=True,
level=config_eos.logging.file_level,
serialize=True, # JSON dict formatting
# format=_file_format
)
# Redirect standard logging to Loguru
pylogging.basicConfig(handlers=[InterceptHandler()], level=0)
# Redirect uvicorn and fastapi logging to Loguru
pylogging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
for pylogger_name in ["uvicorn", "uvicorn.error", "fastapi"]:
pylogger = pylogging.getLogger(pylogger_name)
pylogger.handlers = [InterceptHandler()]
pylogger.propagate = False
logger.info(
f"Logger reconfigured - console: {config_eos.logging.console_level}, file: {config_eos.logging.file_level}."
)
def read_file_log(
log_path: Path,
limit: int = 100,
level: Optional[str] = None,
contains: Optional[str] = None,
regex: Optional[str] = None,
from_time: Optional[str] = None,
to_time: Optional[str] = None,
tail: bool = False,
) -> List[dict]:
"""Read and filter structured log entries from a JSON-formatted log file.
Args: Args:
name (str): The name of the logger, typically `__name__` from the calling module. log_path (Path): Path to the JSON-formatted log file.
log_file (Optional[str]): Path to the log file for file logging. If None, no file logging is done. limit (int, optional): Maximum number of log entries to return. Defaults to 100.
logging_level (Optional[str]): Logging level (e.g., "INFO", "DEBUG"). Defaults to "INFO". level (Optional[str], optional): Filter logs by log level (e.g., "INFO", "ERROR"). Defaults to None.
max_bytes (int): Maximum size in bytes for log file before rotation. Defaults to 5 MB. contains (Optional[str], optional): Filter logs that contain this substring in their message. Case-insensitive. Defaults to None.
backup_count (int): Number of backup log files to keep. Defaults to 5. regex (Optional[str], optional): Filter logs whose message matches this regular expression. Defaults to None.
from_time (Optional[str], optional): ISO 8601 datetime string to filter logs not earlier than this time. Defaults to None.
to_time (Optional[str], optional): ISO 8601 datetime string to filter logs not later than this time. Defaults to None.
tail (bool, optional): If True, read the last lines of the file (like `tail -n`). Defaults to False.
Returns: Returns:
logging.Logger: Configured logger instance. List[dict]: A list of filtered log entries as dictionaries.
Example: Raises:
logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG") FileNotFoundError: If the log file does not exist.
logger.info("Application started") ValueError: If the datetime strings are invalid or improperly formatted.
Exception: For other unforeseen I/O or parsing errors.
""" """
# Create a logger with the specified name if not log_path.exists():
logger = pylogging.getLogger(name) raise FileNotFoundError("Log file not found")
logger.propagate = True
if logging_level is not None:
level = logging_str_to_level(logging_level)
logger.setLevel(level)
# The log message format try:
formatter = pylogging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") from_dt = pendulum.parse(from_time) if from_time else None
to_dt = pendulum.parse(to_time) if to_time else None
except Exception as e:
raise ValueError(f"Invalid date/time format: {e}")
# Prevent loggers from being added multiple times regex_pattern = re.compile(regex) if regex else None
# There may already be a logger from pytest
if not logger.handlers:
# Create a console handler with a standard output stream
console_handler = pylogging.StreamHandler()
if logging_level is not None:
console_handler.setLevel(level)
console_handler.setFormatter(formatter)
# Add the console handler to the logger def matches_filters(log: dict) -> bool:
logger.addHandler(console_handler) if level and log.get("level", {}).get("name") != level.upper():
return False
if contains and contains.lower() not in log.get("message", "").lower():
return False
if regex_pattern and not regex_pattern.search(log.get("message", "")):
return False
if from_dt or to_dt:
try:
log_time = pendulum.parse(log["time"])
except Exception:
return False
if from_dt and log_time < from_dt:
return False
if to_dt and log_time > to_dt:
return False
return True
if log_file and len(logger.handlers) < 2: # We assume a console logger to be the first logger matched_logs = []
# If a log file path is specified, create a rotating file handler lines: list[str] = []
# Ensure the log directory exists if tail:
log_dir = os.path.dirname(log_file) with log_path.open("rb") as f:
if log_dir and not os.path.exists(log_dir): f.seek(0, 2)
os.makedirs(log_dir) end = f.tell()
buffer = bytearray()
pointer = end
# Create a rotating file handler while pointer > 0 and len(lines) < limit * 5:
file_handler = RotatingFileHandler(log_file, maxBytes=max_bytes, backupCount=backup_count) pointer -= 1
if logging_level is not None: f.seek(pointer)
file_handler.setLevel(level) byte = f.read(1)
file_handler.setFormatter(formatter) if byte == b"\n":
if buffer:
line = buffer[::-1].decode("utf-8", errors="ignore")
lines.append(line)
buffer.clear()
else:
buffer.append(byte[0])
if buffer:
line = buffer[::-1].decode("utf-8", errors="ignore")
lines.append(line)
lines = lines[::-1]
else:
with log_path.open("r", encoding="utf-8", newline=None) as f_txt:
lines = f_txt.readlines()
# Add the file handler to the logger for line in lines:
logger.addHandler(file_handler) if not line.strip():
continue
try:
log = json.loads(line)
except json.JSONDecodeError:
continue
if matches_filters(log):
matched_logs.append(log)
if len(matched_logs) >= limit:
break
return logger return matched_logs

View File

@@ -3,43 +3,61 @@
Kept in an extra module to avoid cyclic dependencies on package import. Kept in an extra module to avoid cyclic dependencies on package import.
""" """
import logging from pathlib import Path
import os
from typing import Optional from typing import Optional
from pydantic import Field, computed_field, field_validator from pydantic import Field, computed_field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logabc import logging_str_to_level from akkudoktoreos.core.logabc import LOGGING_LEVELS
class LoggingCommonSettings(SettingsBaseModel): class LoggingCommonSettings(SettingsBaseModel):
"""Common settings for logging.""" """Logging Configuration."""
logging_level_default: Optional[str] = Field( level: Optional[str] = Field(
default=None, description="EOS default logging level." default=None,
deprecated="This is deprecated. Use console_level and file_level instead.",
) )
# Validators console_level: Optional[str] = Field(
@field_validator("logging_level_default", mode="after") default=None,
@classmethod description="Logging level when logging to console.",
def set_default_logging_level(cls, value: Optional[str]) -> Optional[str]: examples=LOGGING_LEVELS,
if isinstance(value, str) and value.upper() == "NONE": )
value = None
if value is None and (env_level := os.getenv("EOS_LOGGING_LEVEL")) is not None: file_level: Optional[str] = Field(
# Take default logging level from special environment variable default=None,
value = env_level description="Logging level when logging to file.",
if value is None: examples=LOGGING_LEVELS,
return None )
level = logging_str_to_level(value)
logging.getLogger().setLevel(level)
return value
# Computed fields
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def logging_level_root(self) -> str: def file_path(self) -> Optional[Path]:
"""Root logger logging level.""" """Computed log file path based on data output path."""
level = logging.getLogger().getEffectiveLevel() try:
level_name = logging.getLevelName(level) path = SettingsBaseModel.config.general.data_output_path / "eos.log"
return level_name except:
# Config may not be fully set up
path = None
return path
# Validators
@field_validator("console_level", "file_level", mode="after")
@classmethod
def validate_level(cls, value: Optional[str]) -> Optional[str]:
"""Validate logging level string."""
if value is None:
# Nothing to set
return None
if isinstance(value, str):
level = value.upper()
if level == "NONE":
return None
if level not in LOGGING_LEVELS:
raise ValueError(f"Logging level {value} not supported")
value = level
else:
raise TypeError(f"Invalid {type(value)} of logging level {value}")
return value

View File

@@ -12,19 +12,35 @@ Key Features:
pandas DataFrames and Series with datetime indexes. pandas DataFrames and Series with datetime indexes.
""" """
import inspect
import json import json
import re import re
from typing import Any, Dict, List, Optional, Type, Union import uuid
import weakref
from copy import deepcopy
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Type,
Union,
get_args,
get_origin,
)
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pandas as pd import pandas as pd
import pendulum import pendulum
from loguru import logger
from pandas.api.types import is_datetime64_any_dtype from pandas.api.types import is_datetime64_any_dtype
from pydantic import ( from pydantic import (
AwareDatetime, AwareDatetime,
BaseModel, BaseModel,
ConfigDict, ConfigDict,
Field, Field,
PrivateAttr,
RootModel, RootModel,
TypeAdapter, TypeAdapter,
ValidationError, ValidationError,
@@ -34,6 +50,25 @@ from pydantic import (
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
# Global weakref dictionary to hold external state per model instance
# Used as a workaround for PrivateAttr not working in e.g. Mixin Classes
_model_private_state: "weakref.WeakKeyDictionary[Union[PydanticBaseModel, PydanticModelNestedValueMixin], Dict[str, Any]]" = weakref.WeakKeyDictionary()
def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]:
def deep_update(source_dict: dict[str, Any], update_dict: dict[str, Any]) -> dict[str, Any]:
for key, value in source_dict.items():
if isinstance(value, dict) and isinstance(update_dict.get(key), dict):
update_dict[key] = deep_update(update_dict[key], value)
else:
update_dict[key] = value
return update_dict
source_dict = source.model_dump(exclude_unset=True)
merged_dict = deep_update(source_dict, deepcopy(update_dict))
return merged_dict
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]): class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
"""Custom type adapter for Pendulum DateTime fields.""" """Custom type adapter for Pendulum DateTime fields."""
@@ -66,11 +101,538 @@ class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
return bool(re.match(iso8601_pattern, value)) return bool(re.match(iso8601_pattern, value))
class PydanticBaseModel(BaseModel): class PydanticModelNestedValueMixin:
"""Base model class with automatic serialization and deserialization of `pendulum.DateTime` fields. """A mixin providing methods to get, set and track nested values within a Pydantic model.
This model serializes pendulum.DateTime objects to ISO 8601 strings and The methods use a '/'-separated path to denote the nested values.
deserializes ISO 8601 strings to pendulum.DateTime objects. Supports handling `Optional`, `List`, and `Dict` types, ensuring correct initialization of
missing attributes.
Example:
class Address(PydanticBaseModel):
city: str
class User(PydanticBaseModel):
name: str
address: Address
def on_city_change(old, new, path):
print(f"{path}: {old} -> {new}")
user = User(name="Alice", address=Address(city="NY"))
user.track_nested_value("address/city", on_city_change)
user.set_nested_value("address/city", "LA") # triggers callback
"""
def track_nested_value(self, path: str, callback: Callable[[Any, str, Any, Any], None]) -> None:
"""Register a callback for a specific path (or subtree).
Callback triggers if set path is equal or deeper.
Args:
path (str): '/'-separated path to track.
callback (callable): Function called as callback(model_instance, set_path, old_value, new_value).
"""
try:
self._validate_path_structure(path)
pass
except:
raise ValueError(f"Path '{path}' is invalid")
path = path.strip("/")
# Use private data workaround
# Should be:
# _nested_value_callbacks: dict[str, list[Callable[[str, Any, Any], None]]]
# = PrivateAttr(default_factory=dict)
nested_value_callbacks = get_private_attr(self, "nested_value_callbacks", dict())
if path not in nested_value_callbacks:
nested_value_callbacks[path] = []
nested_value_callbacks[path].append(callback)
set_private_attr(self, "nested_value_callbacks", nested_value_callbacks)
logger.debug("Nested value callbacks {}", nested_value_callbacks)
def _validate_path_structure(self, path: str) -> None:
"""Validate that a '/'-separated path is structurally valid for this model.
Checks that each segment of the path corresponds to a field or index in the model's type structure,
without requiring that all intermediate values are currently initialized. This method is intended
to ensure that the path could be valid for nested access or assignment, according to the model's
class definition.
Args:
path (str): The '/'-separated attribute/index path to validate (e.g., "address/city" or "items/0/value").
Raises:
ValueError: If any segment of the path does not correspond to a valid field in the model,
or an invalid transition is made (such as an attribute on a non-model).
Example:
class Address(PydanticBaseModel):
city: str
class User(PydanticBaseModel):
name: str
address: Address
user = User(name="Alice", address=Address(city="NY"))
user._validate_path_structure("address/city") # OK
user._validate_path_structure("address/zipcode") # Raises ValueError
"""
path_elements = path.strip("/").split("/")
# The model we are currently working on
model: Any = self
# The model we get the type information from. It is a pydantic BaseModel
parent: BaseModel = model
# The field that provides type information for the current key
# Fields may have nested types that translates to a sequence of keys, not just one
# - my_field: Optional[list[OtherModel]] -> e.g. "myfield/0" for index 0
# parent_key = ["myfield",] ... ["myfield", "0"]
# parent_key_types = [list, OtherModel]
parent_key: list[str] = []
parent_key_types: list = []
for i, key in enumerate(path_elements):
is_final_key = i == len(path_elements) - 1
# Add current key to parent key to enable nested type tracking
parent_key.append(key)
# Get next value
next_value = None
if isinstance(model, BaseModel):
# Track parent and key for possible assignment later
parent = model
parent_key = [
key,
]
parent_key_types = self._get_key_types(model.__class__, key)
# If this is the final key, set the value
if is_final_key:
return
# Attempt to access the next attribute, handling None values
next_value = getattr(model, key, None)
# Handle missing values (initialize dict/list/model if necessary)
if next_value is None:
next_type = parent_key_types[len(parent_key) - 1]
next_value = self._initialize_value(next_type)
elif isinstance(model, list):
# Handle lists
try:
idx = int(key)
except Exception as e:
raise IndexError(
f"Invalid list index '{key}' at '{path}': key = '{key}'; parent = '{parent}', parent_key = '{parent_key}'; model = '{model}'; {e}"
)
# Get next type from parent key type information
next_type = parent_key_types[len(parent_key) - 1]
if len(model) > idx:
next_value = model[idx]
else:
return
if is_final_key:
return
elif isinstance(model, dict):
# Handle dictionaries (auto-create missing keys)
# Get next type from parent key type information
next_type = parent_key_types[len(parent_key) - 1]
if is_final_key:
return
if key not in model:
return
else:
next_value = model[key]
else:
raise KeyError(f"Key '{key}' not found in model.")
# Move deeper
model = next_value
def get_nested_value(self, path: str) -> Any:
"""Retrieve a nested value from the model using a '/'-separated path.
Supports accessing nested attributes and list indices.
Args:
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
Returns:
Any: The retrieved value.
Raises:
KeyError: If a key is not found in the model.
IndexError: If a list index is out of bounds or invalid.
Example:
```python
class Address(PydanticBaseModel):
city: str
class User(PydanticBaseModel):
name: str
address: Address
user = User(name="Alice", address=Address(city="New York"))
city = user.get_nested_value("address/city")
print(city) # Output: "New York"
```
"""
path_elements = path.strip("/").split("/")
model: Any = self
for key in path_elements:
if isinstance(model, list):
try:
model = model[int(key)]
except (ValueError, IndexError) as e:
raise IndexError(f"Invalid list index at '{path}': {key}; {e}")
elif isinstance(model, dict):
try:
model = model[key]
except Exception as e:
raise KeyError(f"Invalid dict key at '{path}': {key}; {e}")
elif isinstance(model, BaseModel):
model = getattr(model, key)
else:
raise KeyError(f"Key '{key}' not found in model.")
return model
def set_nested_value(self, path: str, value: Any) -> None:
"""Set a nested value in the model using a '/'-separated path.
Supports modifying nested attributes and list indices while preserving Pydantic validation.
Automatically initializes missing `Optional`, `Union`, `dict`, and `list` fields if necessary.
If a missing field cannot be initialized, raises an exception.
Triggers the callbacks registered by track_nested_value().
Args:
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
value (Any): The new value to set.
Raises:
KeyError: If a key is not found in the model.
IndexError: If a list index is out of bounds or invalid.
ValueError: If a validation error occurs.
TypeError: If a missing field cannot be initialized.
Example:
```python
class Address(PydanticBaseModel):
city: Optional[str]
class User(PydanticBaseModel):
name: str
address: Optional[Address]
settings: Optional[Dict[str, Any]]
user = User(name="Alice", address=None, settings=None)
user.set_nested_value("address/city", "Los Angeles")
user.set_nested_value("settings/theme", "dark")
print(user.address.city) # Output: "Los Angeles"
print(user.settings) # Output: {'theme': 'dark'}
```
"""
path = path.strip("/")
# Store old value (if possible)
try:
old_value = self.get_nested_value(path)
except Exception as e:
# We can not get the old value
# raise ValueError(f"Can not get old (current) value of '{path}': {e}") from e
old_value = None
# Proceed with core logic
self._set_nested_value(path, value)
# Trigger all callbacks whose path is a prefix of set path
triggered = set()
nested_value_callbacks = get_private_attr(self, "nested_value_callbacks", dict())
for cb_path, callbacks in nested_value_callbacks.items():
# Match: cb_path == path, or cb_path is a prefix (parent) of path
pass
if path == cb_path or path.startswith(cb_path + "/"):
for cb in callbacks:
# Prevent duplicate calls
if (cb_path, id(cb)) not in triggered:
cb(self, path, old_value, value)
triggered.add((cb_path, id(cb)))
def _set_nested_value(self, path: str, value: Any) -> None:
"""Set a nested value core logic.
Args:
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
value (Any): The new value to set.
Raises:
KeyError: If a key is not found in the model.
IndexError: If a list index is out of bounds or invalid.
ValueError: If a validation error occurs.
TypeError: If a missing field cannot be initialized.
"""
path_elements = path.strip("/").split("/")
# The model we are currently working on
model: Any = self
# The model we get the type information from. It is a pydantic BaseModel
parent: BaseModel = model
# The field that provides type information for the current key
# Fields may have nested types that translates to a sequence of keys, not just one
# - my_field: Optional[list[OtherModel]] -> e.g. "myfield/0" for index 0
# parent_key = ["myfield",] ... ["myfield", "0"]
# parent_key_types = [list, OtherModel]
parent_key: list[str] = []
parent_key_types: list = []
for i, key in enumerate(path_elements):
is_final_key = i == len(path_elements) - 1
# Add current key to parent key to enable nested type tracking
parent_key.append(key)
# Get next value
next_value = None
if isinstance(model, BaseModel):
# Track parent and key for possible assignment later
parent = model
parent_key = [
key,
]
parent_key_types = self._get_key_types(model.__class__, key)
# If this is the final key, set the value
if is_final_key:
try:
model.__pydantic_validator__.validate_assignment(model, key, value)
except ValidationError as e:
raise ValueError(f"Error updating model: {e}") from e
return
# Attempt to access the next attribute, handling None values
next_value = getattr(model, key, None)
# Handle missing values (initialize dict/list/model if necessary)
if next_value is None:
next_type = parent_key_types[len(parent_key) - 1]
next_value = self._initialize_value(next_type)
if next_value is None:
raise TypeError(
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
)
setattr(parent, key, next_value)
# pydantic may copy on validation assignment - reread to get the copied model
next_value = getattr(model, key, None)
elif isinstance(model, list):
# Handle lists (ensure index exists and modify safely)
try:
idx = int(key)
except Exception as e:
raise IndexError(
f"Invalid list index '{key}' at '{path}': key = '{key}'; parent = '{parent}', parent_key = '{parent_key}'; model = '{model}'; {e}"
)
# Get next type from parent key type information
next_type = parent_key_types[len(parent_key) - 1]
if len(model) > idx:
next_value = model[idx]
else:
# Extend the list with default values if index is out of range
while len(model) <= idx:
next_value = self._initialize_value(next_type)
if next_value is None:
raise TypeError(
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
)
model.append(next_value)
if is_final_key:
if (
(isinstance(next_type, type) and not isinstance(value, next_type))
or (next_type is dict and not isinstance(value, dict))
or (next_type is list and not isinstance(value, list))
):
raise TypeError(
f"Expected type {next_type} for key '{key}' in path '{path}', but got {type(value)}: {value}"
)
model[idx] = value
return
elif isinstance(model, dict):
# Handle dictionaries (auto-create missing keys)
# Get next type from parent key type information
next_type = parent_key_types[len(parent_key) - 1]
if is_final_key:
if (
(isinstance(next_type, type) and not isinstance(value, next_type))
or (next_type is dict and not isinstance(value, dict))
or (next_type is list and not isinstance(value, list))
):
raise TypeError(
f"Expected type {next_type} for key '{key}' in path '{path}', but got {type(value)}: {value}"
)
model[key] = value
return
if key not in model:
next_value = self._initialize_value(next_type)
if next_value is None:
raise TypeError(
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
)
model[key] = next_value
else:
next_value = model[key]
else:
raise KeyError(f"Key '{key}' not found in model.")
# Move deeper
model = next_value
@staticmethod
def _get_key_types(model: Type[BaseModel], key: str) -> List[Union[Type[Any], list, dict]]:
"""Returns a list of nested types for a given Pydantic model key.
- Skips `Optional` and `Union`, using only the first non-None type.
- Skips dictionary keys and only adds value types.
- Keeps `list` and `dict` as origins.
Args:
model (Type[BaseModel]): The Pydantic model class to inspect.
key (str): The attribute name in the model.
Returns:
List[Union[Type[Any], list, dict]]: A list of extracted types, preserving `list` and `dict` origins.
Raises:
TypeError: If the key does not exist or lacks a valid type annotation.
"""
if not inspect.isclass(model):
raise TypeError(f"Model '{model}' is not of class type.")
if key not in model.model_fields:
raise TypeError(f"Field '{key}' does not exist in model '{model.__name__}'.")
field_annotation = model.model_fields[key].annotation
if not field_annotation:
raise TypeError(
f"Missing type annotation for field '{key}' in model '{model.__name__}'."
)
nested_types: list[Union[Type[Any], list, dict]] = []
queue: list[Any] = [field_annotation]
while queue:
annotation = queue.pop(0)
origin = get_origin(annotation)
args = get_args(annotation)
# Handle Union (Optional[X] is treated as Union[X, None])
if origin is Union:
queue.extend(arg for arg in args if arg is not type(None))
continue
# Handle lists and dictionaries
if origin is list:
nested_types.append(list)
if args:
queue.append(args[0]) # Extract value type for list[T]
continue
if origin is dict:
nested_types.append(dict)
if len(args) == 2:
queue.append(args[1]) # Extract only the value type for dict[K, V]
continue
# If it's a BaseModel, add it to the list
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
nested_types.append(annotation)
continue
# Otherwise, it's a standard type (e.g., str, int, bool, float, etc.)
nested_types.append(annotation)
return nested_types
@staticmethod
def _initialize_value(type_hint: Type[Any] | None | list[Any] | dict[Any, Any]) -> Any:
"""Initialize a missing value based on the provided type hint.
Args:
type_hint (Type[Any] | None | list[Any] | dict[Any, Any]): The type hint that determines
how the missing value should be initialized.
Returns:
Any: An instance of the expected type (e.g., list, dict, or Pydantic model), or `None`
if initialization is not possible.
Raises:
TypeError: If instantiation fails.
Example:
- For `list[str]`, returns `[]`
- For `dict[str, Any]`, returns `{}`
- For `Address` (a Pydantic model), returns a new `Address()` instance.
"""
if type_hint is None:
return None
# Handle direct instances of list or dict
if isinstance(type_hint, list):
return []
if isinstance(type_hint, dict):
return {}
origin = get_origin(type_hint)
# Handle generic list and dictionary
if origin is list:
return []
if origin is dict:
return {}
# Handle Pydantic models
if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
try:
return type_hint.model_construct()
except Exception as e:
raise TypeError(f"Failed to initialize model '{type_hint.__name__}': {e}")
# Handle standard built-in types (int, float, str, bool, etc.)
if isinstance(type_hint, type):
try:
return type_hint()
except Exception as e:
raise TypeError(f"Failed to initialize instance of '{type_hint.__name__}': {e}")
raise TypeError(f"Unsupported type hint '{type_hint}' for initialization.")
class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
"""Base model with pendulum datetime support, nested value utilities, and stable hashing.
This class provides:
- ISO 8601 serialization/deserialization of `pendulum.DateTime` fields.
- Nested attribute access and mutation via `PydanticModelNestedValueMixin`.
- A consistent hash using a UUID for use in sets and as dictionary keys
""" """
# Enable custom serialization globally in config # Enable custom serialization globally in config
@@ -80,6 +642,17 @@ class PydanticBaseModel(BaseModel):
validate_assignment=True, validate_assignment=True,
) )
_uuid: str = PrivateAttr(default_factory=lambda: str(uuid.uuid4()))
"""str: A private UUID string generated on instantiation, used for hashing."""
def __hash__(self) -> int:
"""Returns a stable hash based on the instance's UUID.
Returns:
int: Hash value derived from the model's UUID.
"""
return hash(self._uuid)
@field_validator("*", mode="before") @field_validator("*", mode="before")
def validate_and_convert_pendulum(cls, value: Any, info: ValidationInfo) -> Any: def validate_and_convert_pendulum(cls, value: Any, info: ValidationInfo) -> Any:
"""Validator to convert fields of type `pendulum.DateTime`. """Validator to convert fields of type `pendulum.DateTime`.
@@ -108,14 +681,21 @@ class PydanticBaseModel(BaseModel):
if expected_type is pendulum.DateTime or expected_type is AwareDatetime: if expected_type is pendulum.DateTime or expected_type is AwareDatetime:
try: try:
value = to_datetime(value) value = to_datetime(value)
except: except Exception as e:
pass raise ValueError(f"Cannot convert {value!r} to datetime: {e}")
return value return value
# Override Pydantics serialization for all DateTime fields # Override Pydantics serialization for all DateTime fields
def model_dump(self, *args: Any, **kwargs: Any) -> dict: def model_dump(
self, *args: Any, include_computed_fields: bool = True, **kwargs: Any
) -> dict[str, Any]:
"""Custom dump method to handle serialization for DateTime fields.""" """Custom dump method to handle serialization for DateTime fields."""
result = super().model_dump(*args, **kwargs) result = super().model_dump(*args, **kwargs)
if not include_computed_fields:
for computed_field_name in self.model_computed_fields:
result.pop(computed_field_name, None)
for key, value in result.items(): for key, value in result.items():
if isinstance(value, pendulum.DateTime): if isinstance(value, pendulum.DateTime):
result[key] = PydanticTypeAdapterDateTime.serialize(value) result[key] = PydanticTypeAdapterDateTime.serialize(value)
@@ -170,6 +750,10 @@ class PydanticBaseModel(BaseModel):
""" """
return cls.model_validate(data) return cls.model_validate(data)
def model_dump_json(self, *args: Any, indent: Optional[int] = None, **kwargs: Any) -> str:
data = self.model_dump(*args, **kwargs)
return json.dumps(data, indent=indent, default=str)
def to_json(self) -> str: def to_json(self) -> str:
"""Convert the PydanticBaseModel instance to a JSON string. """Convert the PydanticBaseModel instance to a JSON string.
@@ -346,6 +930,10 @@ class PydanticDateTimeDataFrame(PydanticBaseModel):
index = pd.Index([to_datetime(dt, in_timezone=self.tz) for dt in df.index]) index = pd.Index([to_datetime(dt, in_timezone=self.tz) for dt in df.index])
df.index = index df.index = index
# Check if 'date_time' column exists, if not, create it
if "date_time" not in df.columns:
df["date_time"] = df.index
dtype_mapping = { dtype_mapping = {
"int": int, "int": int,
"float": float, "float": float,
@@ -482,3 +1070,27 @@ class PydanticDateTimeSeries(PydanticBaseModel):
class ParametersBaseModel(PydanticBaseModel): class ParametersBaseModel(PydanticBaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
def set_private_attr(
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str, value: Any
) -> None:
"""Set a private attribute for a model instance (not stored in model itself)."""
if model not in _model_private_state:
_model_private_state[model] = {}
_model_private_state[model][key] = value
def get_private_attr(
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str, default: Any = None
) -> Any:
"""Get a private attribute or return default."""
return _model_private_state.get(model, {}).get(key, default)
def del_private_attr(
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str
) -> None:
"""Delete a private attribute."""
if model in _model_private_state and key in _model_private_state[model]:
del _model_private_state[model][key]

View File

@@ -1,113 +1,2 @@
{ {
"config_file_path": null,
"config_folder_path": null,
"data_cache_path": null,
"data_cache_subpath": null,
"data_folder_path": null,
"data_output_path": null,
"data_output_subpath": null,
"elecprice_charges_kwh": 0.21,
"elecprice_provider": null,
"elecpriceimport_file_path": null,
"latitude": 52.5,
"load_import_file_path": null,
"load_name": null,
"load_provider": null,
"loadakkudoktor_year_energy": null,
"logging_level": "INFO",
"longitude": 13.4,
"optimization_ev_available_charge_rates_percent": null,
"optimization_hours": 48,
"optimization_penalty": null,
"prediction_historic_hours": 48,
"prediction_hours": 48,
"pvforecast0_albedo": null,
"pvforecast0_inverter_model": null,
"pvforecast0_inverter_paco": null,
"pvforecast0_loss": null,
"pvforecast0_module_model": null,
"pvforecast0_modules_per_string": null,
"pvforecast0_mountingplace": "free",
"pvforecast0_optimal_surface_tilt": false,
"pvforecast0_optimalangles": false,
"pvforecast0_peakpower": null,
"pvforecast0_pvtechchoice": "crystSi",
"pvforecast0_strings_per_inverter": null,
"pvforecast0_surface_azimuth": 180,
"pvforecast0_surface_tilt": 0,
"pvforecast0_trackingtype": 0,
"pvforecast0_userhorizon": null,
"pvforecast1_albedo": null,
"pvforecast1_inverter_model": null,
"pvforecast1_inverter_paco": null,
"pvforecast1_loss": 0,
"pvforecast1_module_model": null,
"pvforecast1_modules_per_string": null,
"pvforecast1_mountingplace": "free",
"pvforecast1_optimal_surface_tilt": false,
"pvforecast1_optimalangles": false,
"pvforecast1_peakpower": null,
"pvforecast1_pvtechchoice": "crystSi",
"pvforecast1_strings_per_inverter": null,
"pvforecast1_surface_azimuth": 180,
"pvforecast1_surface_tilt": 0,
"pvforecast1_trackingtype": 0,
"pvforecast1_userhorizon": null,
"pvforecast2_albedo": null,
"pvforecast2_inverter_model": null,
"pvforecast2_inverter_paco": null,
"pvforecast2_loss": 0,
"pvforecast2_module_model": null,
"pvforecast2_modules_per_string": null,
"pvforecast2_mountingplace": "free",
"pvforecast2_optimal_surface_tilt": false,
"pvforecast2_optimalangles": false,
"pvforecast2_peakpower": null,
"pvforecast2_pvtechchoice": "crystSi",
"pvforecast2_strings_per_inverter": null,
"pvforecast2_surface_azimuth": 180,
"pvforecast2_surface_tilt": 0,
"pvforecast2_trackingtype": 0,
"pvforecast2_userhorizon": null,
"pvforecast3_albedo": null,
"pvforecast3_inverter_model": null,
"pvforecast3_inverter_paco": null,
"pvforecast3_loss": 0,
"pvforecast3_module_model": null,
"pvforecast3_modules_per_string": null,
"pvforecast3_mountingplace": "free",
"pvforecast3_optimal_surface_tilt": false,
"pvforecast3_optimalangles": false,
"pvforecast3_peakpower": null,
"pvforecast3_pvtechchoice": "crystSi",
"pvforecast3_strings_per_inverter": null,
"pvforecast3_surface_azimuth": 180,
"pvforecast3_surface_tilt": 0,
"pvforecast3_trackingtype": 0,
"pvforecast3_userhorizon": null,
"pvforecast4_albedo": null,
"pvforecast4_inverter_model": null,
"pvforecast4_inverter_paco": null,
"pvforecast4_loss": 0,
"pvforecast4_module_model": null,
"pvforecast4_modules_per_string": null,
"pvforecast4_mountingplace": "free",
"pvforecast4_optimal_surface_tilt": false,
"pvforecast4_optimalangles": false,
"pvforecast4_peakpower": null,
"pvforecast4_pvtechchoice": "crystSi",
"pvforecast4_strings_per_inverter": null,
"pvforecast4_surface_azimuth": 180,
"pvforecast4_surface_tilt": 0,
"pvforecast4_trackingtype": 0,
"pvforecast4_userhorizon": null,
"pvforecast_provider": null,
"pvforecastimport_file_path": null,
"server_eos_startup_eosdash": true,
"server_eos_host": "0.0.0.0",
"server_eos_port": 8503,
"server_eosdash_host": "0.0.0.0",
"server_eosdash_port": 8504,
"weather_provider": null,
"weatherimport_file_path": null
} }

View File

@@ -1,15 +1,15 @@
from typing import Any, Optional from typing import Any, Optional
import numpy as np import numpy as np
from pydantic import BaseModel, Field, field_validator from pydantic import Field, field_validator
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.devices.devicesabc import (
from akkudoktoreos.core.pydantic import ParametersBaseModel DeviceBase,
from akkudoktoreos.devices.devicesabc import DeviceBase DeviceOptimizeResult,
DeviceParameters,
)
from akkudoktoreos.utils.utils import NumpyEncoder from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
def max_charging_power_field(description: Optional[str] = None) -> float: def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None: if description is None:
@@ -22,14 +22,26 @@ def max_charging_power_field(description: Optional[str] = None) -> float:
def initial_soc_percentage_field(description: str) -> int: def initial_soc_percentage_field(description: str) -> int:
return Field(default=0, ge=0, le=100, description=description) return Field(default=0, ge=0, le=100, description=description, examples=[42])
class BaseBatteryParameters(ParametersBaseModel): def discharging_efficiency_field(default_value: float) -> float:
"""Base class for battery parameters with fields for capacity, efficiency, and state of charge.""" return Field(
default=default_value,
gt=0,
le=1,
description="A float representing the discharge efficiency of the battery.",
)
class BaseBatteryParameters(DeviceParameters):
"""Battery Device Simulation Configuration."""
device_id: str = Field(description="ID of battery", examples=["battery1"])
capacity_wh: int = Field( capacity_wh: int = Field(
gt=0, description="An integer representing the capacity of the battery in watt-hours." gt=0,
description="An integer representing the capacity of the battery in watt-hours.",
examples=[8000],
) )
charging_efficiency: float = Field( charging_efficiency: float = Field(
default=0.88, default=0.88,
@@ -37,12 +49,7 @@ class BaseBatteryParameters(ParametersBaseModel):
le=1, le=1,
description="A float representing the charging efficiency of the battery.", description="A float representing the charging efficiency of the battery.",
) )
discharging_efficiency: float = Field( discharging_efficiency: float = discharging_efficiency_field(0.88)
default=0.88,
gt=0,
le=1,
description="A float representing the discharge efficiency of the battery.",
)
max_charge_power_w: Optional[float] = max_charging_power_field() max_charge_power_w: Optional[float] = max_charging_power_field()
initial_soc_percentage: int = initial_soc_percentage_field( initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)." "An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
@@ -52,6 +59,7 @@ class BaseBatteryParameters(ParametersBaseModel):
ge=0, ge=0,
le=100, le=100,
description="An integer representing the minimum state of charge (SOC) of the battery in percentage.", description="An integer representing the minimum state of charge (SOC) of the battery in percentage.",
examples=[10],
) )
max_soc_percentage: int = Field( max_soc_percentage: int = Field(
default=100, default=100,
@@ -66,17 +74,19 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
class ElectricVehicleParameters(BaseBatteryParameters): class ElectricVehicleParameters(BaseBatteryParameters):
"""Parameters specific to an electric vehicle (EV).""" """Battery Electric Vehicle Device Simulation Configuration."""
discharging_efficiency: float = 1.0 device_id: str = Field(description="ID of electric vehicle", examples=["ev1"])
discharging_efficiency: float = discharging_efficiency_field(1.0)
initial_soc_percentage: int = initial_soc_percentage_field( initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage." "An integer representing the current state of charge (SOC) of the battery in percentage."
) )
class ElectricVehicleResult(BaseModel): class ElectricVehicleResult(DeviceOptimizeResult):
"""Result class containing information related to the electric vehicle's charging and discharging behavior.""" """Result class containing information related to the electric vehicle's charging and discharging behavior."""
device_id: str = Field(description="ID of electric vehicle", examples=["ev1"])
charge_array: list[float] = Field( charge_array: list[float] = Field(
description="Hourly charging status (0 for no charging, 1 for charging)." description="Hourly charging status (0 for no charging, 1 for charging)."
) )
@@ -84,7 +94,6 @@ class ElectricVehicleResult(BaseModel):
description="Hourly discharging status (0 for no discharging, 1 for discharging)." description="Hourly discharging status (0 for no discharging, 1 for discharging)."
) )
discharging_efficiency: float = Field(description="The discharge efficiency as a float..") discharging_efficiency: float = Field(description="The discharge efficiency as a float..")
hours: int = Field(description="Number of hours in the simulation.")
capacity_wh: int = Field(description="Capacity of the EVs battery in watt-hours.") capacity_wh: int = Field(description="Capacity of the EVs battery in watt-hours.")
charging_efficiency: float = Field(description="Charging efficiency as a float..") charging_efficiency: float = Field(description="Charging efficiency as a float..")
max_charge_power_w: int = Field(description="Maximum charging power in watts.") max_charge_power_w: int = Field(description="Maximum charging power in watts.")
@@ -103,81 +112,31 @@ class ElectricVehicleResult(BaseModel):
class Battery(DeviceBase): class Battery(DeviceBase):
"""Represents a battery device with methods to simulate energy charging and discharging.""" """Represents a battery device with methods to simulate energy charging and discharging."""
def __init__( def __init__(self, parameters: Optional[BaseBatteryParameters] = None):
self, self.parameters: Optional[BaseBatteryParameters] = None
parameters: Optional[BaseBatteryParameters] = None, super().__init__(parameters)
hours: Optional[int] = 24,
provider_id: Optional[str] = None,
):
# Initialize configuration and parameters
self.provider_id = provider_id
self.prefix = "<invalid>"
if self.provider_id == "GenericBattery":
self.prefix = "battery"
elif self.provider_id == "GenericBEV":
self.prefix = "bev"
self.parameters = parameters def _setup(self) -> None:
if hours is None:
self.hours = self.total_hours # TODO where does that come from?
else:
self.hours = hours
self.initialised = False
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
if self.parameters is not None:
self.setup()
def setup(self) -> None:
"""Sets up the battery parameters based on configuration or provided parameters.""" """Sets up the battery parameters based on configuration or provided parameters."""
if self.initialised: if self.parameters is None:
return raise ValueError(f"Parameters not set: {self.parameters}")
self.capacity_wh = self.parameters.capacity_wh
self.initial_soc_percentage = self.parameters.initial_soc_percentage
self.charging_efficiency = self.parameters.charging_efficiency
self.discharging_efficiency = self.parameters.discharging_efficiency
if self.provider_id: # Only assign for storage battery
# Setup from configuration self.min_soc_percentage = (
self.capacity_wh = getattr(self.config, f"{self.prefix}_capacity") self.parameters.min_soc_percentage
self.initial_soc_percentage = getattr(self.config, f"{self.prefix}_initial_soc") if isinstance(self.parameters, SolarPanelBatteryParameters)
self.hours = self.total_hours # TODO where does that come from? else 0
self.charging_efficiency = getattr(self.config, f"{self.prefix}_charging_efficiency") )
self.discharging_efficiency = getattr( self.max_soc_percentage = self.parameters.max_soc_percentage
self.config, f"{self.prefix}_discharging_efficiency"
)
self.max_charge_power_w = getattr(self.config, f"{self.prefix}_max_charging_power")
if self.provider_id == "GenericBattery":
self.min_soc_percentage = getattr(
self.config,
f"{self.prefix}_soc_min",
)
else:
self.min_soc_percentage = 0
self.max_soc_percentage = getattr(
self.config,
f"{self.prefix}_soc_max",
)
elif self.parameters:
# Setup from parameters
self.capacity_wh = self.parameters.capacity_wh
self.initial_soc_percentage = self.parameters.initial_soc_percentage
self.charging_efficiency = self.parameters.charging_efficiency
self.discharging_efficiency = self.parameters.discharging_efficiency
self.max_charge_power_w = self.parameters.max_charge_power_w
# Only assign for storage battery
self.min_soc_percentage = (
self.parameters.min_soc_percentage
if isinstance(self.parameters, SolarPanelBatteryParameters)
else 0
)
self.max_soc_percentage = self.parameters.max_soc_percentage
else:
error_msg = "Parameters and provider ID are missing. Cannot instantiate."
logger.error(error_msg)
raise ValueError(error_msg)
# Initialize state of charge # Initialize state of charge
if self.max_charge_power_w is None: if self.parameters.max_charge_power_w is not None:
self.max_charge_power_w = self.parameters.max_charge_power_w
else:
self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
self.discharge_array = np.full(self.hours, 1) self.discharge_array = np.full(self.hours, 1)
self.charge_array = np.full(self.hours, 1) self.charge_array = np.full(self.hours, 1)
@@ -185,11 +144,10 @@ class Battery(DeviceBase):
self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh
self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh
self.initialised = True
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""Converts the object to a dictionary representation.""" """Converts the object to a dictionary representation."""
return { return {
"device_id": self.device_id,
"capacity_wh": self.capacity_wh, "capacity_wh": self.capacity_wh,
"initial_soc_percentage": self.initial_soc_percentage, "initial_soc_percentage": self.initial_soc_percentage,
"soc_wh": self.soc_wh, "soc_wh": self.soc_wh,

View File

@@ -1,313 +1,49 @@
from typing import Any, ClassVar, Dict, Optional, Union from typing import Optional
import numpy as np
from numpydantic import NDArray, Shape
from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import SingletonMixin from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.devices.battery import Battery from akkudoktoreos.devices.battery import Battery
from akkudoktoreos.devices.devicesabc import DevicesBase from akkudoktoreos.devices.devicesabc import DevicesBase
from akkudoktoreos.devices.generic import HomeAppliance from akkudoktoreos.devices.generic import HomeAppliance
from akkudoktoreos.devices.inverter import Inverter from akkudoktoreos.devices.inverter import Inverter
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator from akkudoktoreos.devices.settings import DevicesCommonSettings
from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__)
class DevicesCommonSettings(SettingsBaseModel):
"""Base configuration for devices simulation settings."""
# Battery
# -------
battery_provider: Optional[str] = Field(
default=None, description="Id of Battery simulation provider."
)
battery_capacity: Optional[int] = Field(default=None, description="Battery capacity [Wh].")
battery_initial_soc: Optional[int] = Field(
default=None, description="Battery initial state of charge [%]."
)
battery_soc_min: Optional[int] = Field(
default=None, description="Battery minimum state of charge [%]."
)
battery_soc_max: Optional[int] = Field(
default=None, description="Battery maximum state of charge [%]."
)
battery_charging_efficiency: Optional[float] = Field(
default=None, description="Battery charging efficiency [%]."
)
battery_discharging_efficiency: Optional[float] = Field(
default=None, description="Battery discharging efficiency [%]."
)
battery_max_charging_power: Optional[int] = Field(
default=None, description="Battery maximum charge power [W]."
)
# Battery Electric Vehicle
# ------------------------
bev_provider: Optional[str] = Field(
default=None, description="Id of Battery Electric Vehicle simulation provider."
)
bev_capacity: Optional[int] = Field(
default=None, description="Battery Electric Vehicle capacity [Wh]."
)
bev_initial_soc: Optional[int] = Field(
default=None, description="Battery Electric Vehicle initial state of charge [%]."
)
bev_soc_max: Optional[int] = Field(
default=None, description="Battery Electric Vehicle maximum state of charge [%]."
)
bev_charging_efficiency: Optional[float] = Field(
default=None, description="Battery Electric Vehicle charging efficiency [%]."
)
bev_discharging_efficiency: Optional[float] = Field(
default=None, description="Battery Electric Vehicle discharging efficiency [%]."
)
bev_max_charging_power: Optional[int] = Field(
default=None, description="Battery Electric Vehicle maximum charge power [W]."
)
# Home Appliance - Dish Washer
# ----------------------------
dishwasher_provider: Optional[str] = Field(
default=None, description="Id of Dish Washer simulation provider."
)
dishwasher_consumption: Optional[int] = Field(
default=None, description="Dish Washer energy consumption [Wh]."
)
dishwasher_duration: Optional[int] = Field(
default=None, description="Dish Washer usage duration [h]."
)
# PV Inverter
# -----------
inverter_provider: Optional[str] = Field(
default=None, description="Id of PV Inverter simulation provider."
)
inverter_power_max: Optional[float] = Field(
default=None, description="Inverter maximum power [W]."
)
class Devices(SingletonMixin, DevicesBase): class Devices(SingletonMixin, DevicesBase):
# Results of the devices simulation and def __init__(self, settings: Optional[DevicesCommonSettings] = None):
# insights into various parameters over the entire forecast period. if hasattr(self, "_initialized"):
# ----------------------------------------------------------------- return
last_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( super().__init__()
default=None, description="The load in watt-hours per hour." if settings is None:
) settings = self.config.devices
eauto_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( if settings is None:
default=None, description="The state of charge of the EV for each hour." return
)
einnahmen_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="The revenue from grid feed-in or other sources in euros per hour.",
)
home_appliance_wh_per_hour: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="The energy consumption of a household appliance in watt-hours per hour.",
)
kosten_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The costs in euros per hour."
)
grid_import_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The grid energy drawn in watt-hours per hour."
)
grid_export_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The energy fed into the grid in watt-hours per hour."
)
verluste_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None, description="The losses in watt-hours per hour."
)
akku_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="The state of charge of the battery (not the EV) in percentage per hour.",
)
# Computed fields # initialize devices
@computed_field # type: ignore[prop-decorator] if settings.batteries is not None:
@property for battery_params in settings.batteries:
def total_balance_euro(self) -> float: self.add_device(Battery(battery_params))
"""The total balance of revenues minus costs in euros.""" if settings.inverters is not None:
return self.total_revenues_euro - self.total_costs_euro for inverter_params in settings.inverters:
self.add_device(Inverter(inverter_params))
if settings.home_appliances is not None:
for home_appliance_params in settings.home_appliances:
self.add_device(HomeAppliance(home_appliance_params))
@computed_field # type: ignore[prop-decorator] self.post_setup()
@property
def total_revenues_euro(self) -> float:
"""The total revenues in euros."""
if self.einnahmen_euro_pro_stunde is None:
return 0
return np.nansum(self.einnahmen_euro_pro_stunde)
@computed_field # type: ignore[prop-decorator] def post_setup(self) -> None:
@property for device in self.devices.values():
def total_costs_euro(self) -> float: device.post_setup()
"""The total costs in euros."""
if self.kosten_euro_pro_stunde is None:
return 0
return np.nansum(self.kosten_euro_pro_stunde)
@computed_field # type: ignore[prop-decorator]
@property
def total_losses_wh(self) -> float:
"""The total losses in watt-hours over the entire period."""
if self.verluste_wh_pro_stunde is None:
return 0
return np.nansum(self.verluste_wh_pro_stunde)
# Devices
# TODO: Make devices class a container of device simulation providers.
# Device simulations to be used are then enabled in the configuration.
battery: ClassVar[Battery] = Battery(provider_id="GenericBattery")
ev: ClassVar[Battery] = Battery(provider_id="GenericBEV")
home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher")
inverter: ClassVar[Inverter] = Inverter(
self_consumption_predictor=SelfConsumptionProbabilityInterpolator,
battery=battery,
provider_id="GenericInverter",
)
def update_data(self) -> None:
"""Update device simulation data."""
# Assure devices are set up
self.battery.setup()
self.ev.setup()
self.home_appliance.setup()
self.inverter.setup()
# Pre-allocate arrays for the results, optimized for speed
self.last_wh_pro_stunde = np.full((self.total_hours), np.nan)
self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan)
self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan)
self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan)
self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan)
self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan)
self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan)
self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan)
self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan)
# Set initial state
simulation_step = to_duration("1 hour")
if self.battery:
self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage()
if self.ev:
self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage()
# Get predictions for full device simulation time range
# gesamtlast[stunde]
load_total_mean = self.prediction.key_to_array(
"load_total_mean",
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=simulation_step,
)
# pv_prognose_wh[stunde]
pvforecast_ac_power = self.prediction.key_to_array(
"pvforecast_ac_power",
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=simulation_step,
)
# strompreis_euro_pro_wh[stunde]
elecprice_marketprice_wh = self.prediction.key_to_array(
"elecprice_marketprice_wh",
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=simulation_step,
)
# einspeiseverguetung_euro_pro_wh_arr[stunde]
# TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr
einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078)
for stunde_since_now in range(0, self.total_hours):
hour = self.start_datetime.hour + stunde_since_now
# Accumulate loads and PV generation
consumption = load_total_mean[stunde_since_now]
self.verluste_wh_pro_stunde[stunde_since_now] = 0.0
# Home appliances
if self.home_appliance:
ha_load = self.home_appliance.get_load_for_hour(hour)
consumption += ha_load
self.home_appliance_wh_per_hour[stunde_since_now] = ha_load
# E-Auto handling
if self.ev:
if self.ev_charge_hours[hour] > 0:
geladene_menge_eauto, verluste_eauto = self.ev.charge_energy(
None, hour, relative_power=self.ev_charge_hours[hour]
)
consumption += geladene_menge_eauto
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto
self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage()
# Process inverter logic
grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0)
if self.battery:
self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour)
if self.inverter:
generation = pvforecast_ac_power[hour]
grid_export, grid_import, losses, self_consumption = self.inverter.process_energy(
generation, consumption, hour
)
# AC PV Battery Charge
if self.battery and self.ac_charge_hours[hour] > 0.0:
self.battery.set_charge_allowed_for_hour(1, hour)
geladene_menge, verluste_wh = self.battery.charge_energy(
None, hour, relative_power=self.ac_charge_hours[hour]
)
# print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage())
consumption += geladene_menge
grid_import += geladene_menge
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh
self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export
self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import
self.verluste_wh_pro_stunde[stunde_since_now] += losses
self.last_wh_pro_stunde[stunde_since_now] = consumption
# Financial calculations
self.kosten_euro_pro_stunde[stunde_since_now] = (
grid_import * self.strompreis_euro_pro_wh[hour]
)
self.einnahmen_euro_pro_stunde[stunde_since_now] = (
grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour]
)
# battery SOC tracking
if self.battery:
self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage()
else:
self.akku_soc_pro_stunde[stunde_since_now] = 0.0
def report_dict(self) -> Dict[str, Any]:
"""Provides devices simulation output as a dictionary."""
out: Dict[str, Optional[Union[np.ndarray, float]]] = {
"Last_Wh_pro_Stunde": self.last_wh_pro_stunde,
"grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde,
"grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde,
"Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde,
"akku_soc_pro_stunde": self.akku_soc_pro_stunde,
"Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde,
"Gesamtbilanz_Euro": self.total_balance_euro,
"EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde,
"Gesamteinnahmen_Euro": self.total_revenues_euro,
"Gesamtkosten_Euro": self.total_costs_euro,
"Verluste_Pro_Stunde": self.verluste_wh_pro_stunde,
"Gesamt_Verluste": self.total_losses_wh,
"Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour,
}
return out
# Initialize the Devices simulation, it is a singleton. # Initialize the Devices simulation, it is a singleton.
devices = Devices() devices: Optional[Devices] = None
def get_devices() -> Devices: def get_devices() -> Devices:
global devices
# Fix circular import at runtime
if devices is None:
devices = Devices()
"""Gets the EOS Devices simulation.""" """Gets the EOS Devices simulation."""
return devices return devices

View File

@@ -1,20 +1,41 @@
"""Abstract and base classes for devices.""" """Abstract and base classes for devices."""
from typing import Optional from enum import Enum
from typing import Optional, Type
from loguru import logger
from pendulum import DateTime from pendulum import DateTime
from pydantic import ConfigDict, computed_field from pydantic import Field, computed_field
from akkudoktoreos.core.coreabc import ( from akkudoktoreos.core.coreabc import (
ConfigMixin, ConfigMixin,
DevicesMixin,
EnergyManagementSystemMixin, EnergyManagementSystemMixin,
PredictionMixin, PredictionMixin,
) )
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.utils.datetimeutil import to_duration from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__)
class DeviceParameters(ParametersBaseModel):
device_id: str = Field(description="ID of device", examples="device1")
hours: Optional[int] = Field(
default=None,
gt=0,
description="Number of prediction hours. Defaults to global config prediction hours.",
examples=[None],
)
class DeviceOptimizeResult(ParametersBaseModel):
device_id: str = Field(description="ID of device", examples=["device1"])
hours: int = Field(gt=0, description="Number of hours in the simulation.", examples=[24])
class DeviceState(Enum):
UNINITIALIZED = 0
PREPARED = 1
INITIALIZED = 2
class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin): class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
@@ -28,16 +49,16 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def end_datetime(self) -> Optional[DateTime]: def end_datetime(self) -> Optional[DateTime]:
"""Compute the end datetime based on the `start_datetime` and `prediction_hours`. """Compute the end datetime based on the `start_datetime` and `hours`.
Ajusts the calculated end time if DST transitions occur within the prediction window. Ajusts the calculated end time if DST transitions occur within the prediction window.
Returns: Returns:
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing. Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
""" """
if self.ems.start_datetime and self.config.prediction_hours: if self.ems.start_datetime and self.config.prediction.hours:
end_datetime = self.ems.start_datetime + to_duration( end_datetime = self.ems.start_datetime + to_duration(
f"{self.config.prediction_hours} hours" f"{self.config.prediction.hours} hours"
) )
dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours
logger.debug( logger.debug(
@@ -68,33 +89,93 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
return int(duration.total_hours()) return int(duration.total_hours())
class DeviceBase(DevicesStartEndMixin, PredictionMixin): class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin):
"""Base class for device simulations. """Base class for device simulations.
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute
`prediction`). `prediction`) and EOS device registry (attribute `devices`).
Note: Behavior:
Validation on assignment of the Pydantic model is disabled to speed up simulation runs. - Several initialization phases (setup, post_setup):
- setup: Initialize class attributes from DeviceParameters (pydantic input validation)
- post_setup: Set connections between devices
- NotImplemented:
- hooks during optimization
Notes:
- This class is base to concrete devices like battery, inverter, etc. that are used in optimization.
- Not a pydantic model for a low footprint during optimization.
""" """
# Disable validation on assignment to speed up simulation runs. def __init__(self, parameters: Optional[DeviceParameters] = None):
model_config = ConfigDict( self.device_id: str = "<invalid>"
validate_assignment=False, self.parameters: Optional[DeviceParameters] = None
) self.hours = -1
if self.total_hours is not None:
self.hours = self.total_hours
self.initialized = DeviceState.UNINITIALIZED
if parameters is not None:
self.setup(parameters)
def setup(self, parameters: DeviceParameters) -> None:
if self.initialized != DeviceState.UNINITIALIZED:
return
self.parameters = parameters
self.device_id = self.parameters.device_id
if self.parameters.hours is not None:
self.hours = self.parameters.hours
if self.hours < 0:
raise ValueError("hours is unset")
self._setup()
self.initialized = DeviceState.PREPARED
def post_setup(self) -> None:
if self.initialized.value >= DeviceState.INITIALIZED.value:
return
self._post_setup()
self.initialized = DeviceState.INITIALIZED
def _setup(self) -> None:
"""Implement custom setup in derived device classes."""
pass
def _post_setup(self) -> None:
"""Implement custom setup in derived device classes that is run when all devices are initialized."""
pass
class DevicesBase(DevicesStartEndMixin, PredictionMixin, PydanticBaseModel): class DevicesBase(DevicesStartEndMixin, PredictionMixin):
"""Base class for handling device data. """Base class for handling device data.
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
`prediction`). `prediction`).
Note:
Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
""" """
# Disable validation on assignment to speed up simulation runs. def __init__(self) -> None:
model_config = ConfigDict( super().__init__()
validate_assignment=False, self.devices: dict[str, "DeviceBase"] = dict()
)
def get_device_by_id(self, device_id: str) -> Optional["DeviceBase"]:
return self.devices.get(device_id)
def add_device(self, device: Optional["DeviceBase"]) -> None:
if device is None:
return
if device.device_id in self.devices:
raise ValueError(f"{device.device_id} already registered")
self.devices[device.device_id] = device
def remove_device(self, device: Type["DeviceBase"] | str) -> bool:
if isinstance(device, DeviceBase):
device = device.device_id
return self.devices.pop(device, None) is not None # type: ignore[arg-type]
def reset(self) -> None:
self.devices = dict()

View File

@@ -3,21 +3,22 @@ from typing import Optional
import numpy as np import numpy as np
from pydantic import Field from pydantic import Field
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.devices.devicesabc import DeviceBase
logger = get_logger(__name__)
class HomeApplianceParameters(ParametersBaseModel): class HomeApplianceParameters(DeviceParameters):
"""Home Appliance Device Simulation Configuration."""
device_id: str = Field(description="ID of home appliance", examples=["dishwasher"])
consumption_wh: int = Field( consumption_wh: int = Field(
gt=0, gt=0,
description="An integer representing the energy consumption of a household device in watt-hours.", description="An integer representing the energy consumption of a household device in watt-hours.",
examples=[2000],
) )
duration_h: int = Field( duration_h: int = Field(
gt=0, gt=0,
description="An integer representing the usage duration of a household device in hours.", description="An integer representing the usage duration of a household device in hours.",
examples=[3],
) )
@@ -25,46 +26,16 @@ class HomeAppliance(DeviceBase):
def __init__( def __init__(
self, self,
parameters: Optional[HomeApplianceParameters] = None, parameters: Optional[HomeApplianceParameters] = None,
hours: Optional[int] = 24,
provider_id: Optional[str] = None,
): ):
# Configuration initialisation self.parameters: Optional[HomeApplianceParameters] = None
self.provider_id = provider_id super().__init__(parameters)
self.prefix = "<invalid>"
if self.provider_id == "GenericDishWasher":
self.prefix = "dishwasher"
# Parameter initialisiation
self.parameters = parameters
if hours is None:
self.hours = self.total_hours
else:
self.hours = hours
self.initialised = False def _setup(self) -> None:
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised. if self.parameters is None:
if self.parameters is not None: raise ValueError(f"Parameters not set: {self.parameters}")
self.setup()
def setup(self) -> None:
if self.initialised:
return
if self.provider_id is not None:
# Setup by configuration
self.hours = self.total_hours
self.consumption_wh = getattr(self.config, f"{self.prefix}_consumption")
self.duration_h = getattr(self.config, f"{self.prefix}_duration")
elif self.parameters is not None:
# Setup by parameters
self.consumption_wh = (
self.parameters.consumption_wh
) # Total energy consumption of the device in kWh
self.duration_h = self.parameters.duration_h # Duration of use in hours
else:
error_msg = "Parameters and provider ID missing. Can't instantiate."
logger.error(error_msg)
raise ValueError(error_msg)
self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros
self.initialised = True self.duration_h = self.parameters.duration_h
self.consumption_wh = self.parameters.consumption_wh
def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None: def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None:
"""Sets the start time of the device and generates the corresponding load curve. """Sets the start time of the device and generates the corresponding load curve.

View File

@@ -1,6 +1,7 @@
import logging
from typing import List, Sequence from typing import List, Sequence
from loguru import logger
class Heatpump: class Heatpump:
MAX_HEAT_OUTPUT = 5000 MAX_HEAT_OUTPUT = 5000
@@ -18,10 +19,9 @@ class Heatpump:
COP_COEFFICIENT = 0.1 COP_COEFFICIENT = 0.1
"""COP increase per degree""" """COP increase per degree"""
def __init__(self, max_heat_output: int, prediction_hours: int): def __init__(self, max_heat_output: int, hours: int):
self.max_heat_output = max_heat_output self.max_heat_output = max_heat_output
self.prediction_hours = prediction_hours self.hours = hours
self.log = logging.getLogger(__name__)
def __check_outside_temperature_range__(self, temp_celsius: float) -> bool: def __check_outside_temperature_range__(self, temp_celsius: float) -> bool:
"""Check if temperature is in valid range between -100 and 100 degree Celsius. """Check if temperature is in valid range between -100 and 100 degree Celsius.
@@ -58,7 +58,7 @@ class Heatpump:
f"Outside temperature '{outside_temperature_celsius}' not in range " f"Outside temperature '{outside_temperature_celsius}' not in range "
"(min: -100 Celsius, max: 100 Celsius)" "(min: -100 Celsius, max: 100 Celsius)"
) )
self.log.error(err_msg) logger.error(err_msg)
raise ValueError(err_msg) raise ValueError(err_msg)
def calculate_heating_output(self, outside_temperature_celsius: float) -> float: def calculate_heating_output(self, outside_temperature_celsius: float) -> float:
@@ -86,7 +86,7 @@ class Heatpump:
f"Outside temperature '{outside_temperature_celsius}' not in range " f"Outside temperature '{outside_temperature_celsius}' not in range "
"(min: -100 Celsius, max: 100 Celsius)" "(min: -100 Celsius, max: 100 Celsius)"
) )
self.log.error(err_msg) logger.error(err_msg)
raise ValueError(err_msg) raise ValueError(err_msg)
def calculate_heat_power(self, outside_temperature_celsius: float) -> float: def calculate_heat_power(self, outside_temperature_celsius: float) -> float:
@@ -110,16 +110,16 @@ class Heatpump:
f"Outside temperature '{outside_temperature_celsius}' not in range " f"Outside temperature '{outside_temperature_celsius}' not in range "
"(min: -100 Celsius, max: 100 Celsius)" "(min: -100 Celsius, max: 100 Celsius)"
) )
self.log.error(err_msg) logger.error(err_msg)
raise ValueError(err_msg) raise ValueError(err_msg)
def simulate_24h(self, temperatures: Sequence[float]) -> List[float]: def simulate_24h(self, temperatures: Sequence[float]) -> List[float]:
"""Simulate power data for 24 hours based on provided temperatures.""" """Simulate power data for 24 hours based on provided temperatures."""
power_data: List[float] = [] power_data: List[float] = []
if len(temperatures) != self.prediction_hours: if len(temperatures) != self.hours:
raise ValueError( raise ValueError(
f"The temperature array must contain exactly {self.prediction_hours} entries, " f"The temperature array must contain exactly {self.hours} entries, "
"one for each hour of the day." "one for each hour of the day."
) )

View File

@@ -1,64 +1,64 @@
from typing import Optional from typing import Optional
from loguru import logger
from pydantic import Field from pydantic import Field
from scipy.interpolate import RegularGridInterpolator
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
from akkudoktoreos.core.pydantic import ParametersBaseModel from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
from akkudoktoreos.devices.battery import Battery
from akkudoktoreos.devices.devicesabc import DeviceBase
logger = get_logger(__name__)
class InverterParameters(ParametersBaseModel): class InverterParameters(DeviceParameters):
max_power_wh: float = Field(gt=0) """Inverter Device Simulation Configuration."""
device_id: str = Field(description="ID of inverter", examples=["inverter1"])
max_power_wh: float = Field(gt=0, examples=[10000])
battery_id: Optional[str] = Field(
default=None, description="ID of battery", examples=[None, "battery1"]
)
class Inverter(DeviceBase): class Inverter(DeviceBase):
def __init__( def __init__(
self, self,
self_consumption_predictor: RegularGridInterpolator,
parameters: Optional[InverterParameters] = None, parameters: Optional[InverterParameters] = None,
battery: Optional[Battery] = None,
provider_id: Optional[str] = None,
): ):
# Configuration initialisation self.parameters: Optional[InverterParameters] = None
self.provider_id = provider_id super().__init__(parameters)
self.prefix = "<invalid>"
if self.provider_id == "GenericInverter": self.scr_lookup: dict = {}
self.prefix = "inverter"
# Parameter initialisiation def _calculate_scr(self, consumption: float, generation: float) -> float:
self.parameters = parameters """Check if the consumption and production is in the lookup table. If not, calculate and store the value."""
if battery is None: if consumption not in self.scr_lookup:
self.scr_lookup[consumption] = {}
if generation not in self.scr_lookup[consumption]:
scr = self.self_consumption_predictor.calculate_self_consumption(
consumption, generation
)
self.scr_lookup[consumption][generation] = scr
return scr
return self.scr_lookup[consumption][generation]
def _setup(self) -> None:
if self.parameters is None:
raise ValueError(f"Parameters not set: {self.parameters}")
if self.parameters.battery_id is None:
# For the moment raise exception # For the moment raise exception
# TODO: Make battery configurable by config # TODO: Make battery configurable by config
error_msg = "Battery for PV inverter is mandatory." error_msg = "Battery for PV inverter is mandatory."
logger.error(error_msg) logger.error(error_msg)
raise NotImplementedError(error_msg) raise NotImplementedError(error_msg)
self.battery = battery # Connection to a battery object self.self_consumption_predictor = get_eos_load_interpolator()
self.self_consumption_predictor = self_consumption_predictor self.max_power_wh = (
self.parameters.max_power_wh
) # Maximum power that the inverter can handle
self.initialised = False def _post_setup(self) -> None:
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised. if self.parameters is None:
if self.parameters is not None: raise ValueError(f"Parameters not set: {self.parameters}")
self.setup() self.battery = self.devices.get_device_by_id(self.parameters.battery_id)
def setup(self) -> None:
if self.initialised:
return
if self.provider_id is not None:
# Setup by configuration
self.max_power_wh = getattr(self.config, f"{self.prefix}_power_max")
elif self.parameters is not None:
# Setup by parameters
self.max_power_wh = (
self.parameters.max_power_wh # Maximum power that the inverter can handle
)
else:
error_msg = "Parameters and provider ID missing. Can't instantiate."
logger.error(error_msg)
raise ValueError(error_msg)
def process_energy( def process_energy(
self, generation: float, consumption: float, hour: int self, generation: float, consumption: float, hour: int
@@ -76,9 +76,8 @@ class Inverter(DeviceBase):
grid_import = -remaining_power # Negative indicates feeding into the grid grid_import = -remaining_power # Negative indicates feeding into the grid
self_consumption = self.max_power_wh self_consumption = self.max_power_wh
else: else:
scr = self.self_consumption_predictor.calculate_self_consumption( # Calculate scr with lookup table
consumption, generation scr = self._calculate_scr(consumption, generation)
)
# Remaining power after consumption # Remaining power after consumption
remaining_power = (generation - consumption) * scr # EVQ remaining_power = (generation - consumption) * scr # EVQ

View File

@@ -0,0 +1,24 @@
from typing import Optional
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.devices.battery import BaseBatteryParameters
from akkudoktoreos.devices.generic import HomeApplianceParameters
from akkudoktoreos.devices.inverter import InverterParameters
class DevicesCommonSettings(SettingsBaseModel):
"""Base configuration for devices simulation settings."""
batteries: Optional[list[BaseBatteryParameters]] = Field(
default=None,
description="List of battery/ev devices",
examples=[[{"device_id": "battery1", "capacity_wh": 8000}]],
)
inverters: Optional[list[InverterParameters]] = Field(
default=None, description="List of inverters", examples=[[]]
)
home_appliances: Optional[list[HomeApplianceParameters]] = Field(
default=None, description="List of home appliances", examples=[[]]
)

View File

@@ -9,6 +9,7 @@ The measurements can be added programmatically or imported from a file or JSON s
from typing import Any, ClassVar, List, Optional from typing import Any, ClassVar, List, Optional
import numpy as np import numpy as np
from loguru import logger
from numpydantic import NDArray, Shape from numpydantic import NDArray, Shape
from pendulum import DateTime, Duration from pendulum import DateTime, Duration
from pydantic import Field, computed_field from pydantic import Field, computed_field
@@ -16,27 +17,26 @@ from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import SingletonMixin from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.dataabc import DataImportMixin, DataRecord, DataSequence from akkudoktoreos.core.dataabc import DataImportMixin, DataRecord, DataSequence
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.utils.datetimeutil import to_duration from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__)
class MeasurementCommonSettings(SettingsBaseModel): class MeasurementCommonSettings(SettingsBaseModel):
measurement_load0_name: Optional[str] = Field( """Measurement Configuration."""
default=None, description="Name of the load0 source (e.g. 'Household', 'Heat Pump')"
load0_name: Optional[str] = Field(
default=None, description="Name of the load0 source", examples=["Household", "Heat Pump"]
) )
measurement_load1_name: Optional[str] = Field( load1_name: Optional[str] = Field(
default=None, description="Name of the load1 source (e.g. 'Household', 'Heat Pump')" default=None, description="Name of the load1 source", examples=[None]
) )
measurement_load2_name: Optional[str] = Field( load2_name: Optional[str] = Field(
default=None, description="Name of the load2 source (e.g. 'Household', 'Heat Pump')" default=None, description="Name of the load2 source", examples=[None]
) )
measurement_load3_name: Optional[str] = Field( load3_name: Optional[str] = Field(
default=None, description="Name of the load3 source (e.g. 'Household', 'Heat Pump')" default=None, description="Name of the load3 source", examples=[None]
) )
measurement_load4_name: Optional[str] = Field( load4_name: Optional[str] = Field(
default=None, description="Name of the load4 source (e.g. 'Household', 'Heat Pump')" default=None, description="Name of the load4 source", examples=[None]
) )
@@ -48,42 +48,42 @@ class MeasurementDataRecord(DataRecord):
""" """
# Single loads, to be aggregated to total load # Single loads, to be aggregated to total load
measurement_load0_mr: Optional[float] = Field( load0_mr: Optional[float] = Field(
default=None, ge=0, description="Load0 meter reading [kWh]" default=None, ge=0, description="Load0 meter reading [kWh]", examples=[40421]
) )
measurement_load1_mr: Optional[float] = Field( load1_mr: Optional[float] = Field(
default=None, ge=0, description="Load1 meter reading [kWh]" default=None, ge=0, description="Load1 meter reading [kWh]", examples=[None]
) )
measurement_load2_mr: Optional[float] = Field( load2_mr: Optional[float] = Field(
default=None, ge=0, description="Load2 meter reading [kWh]" default=None, ge=0, description="Load2 meter reading [kWh]", examples=[None]
) )
measurement_load3_mr: Optional[float] = Field( load3_mr: Optional[float] = Field(
default=None, ge=0, description="Load3 meter reading [kWh]" default=None, ge=0, description="Load3 meter reading [kWh]", examples=[None]
) )
measurement_load4_mr: Optional[float] = Field( load4_mr: Optional[float] = Field(
default=None, ge=0, description="Load4 meter reading [kWh]" default=None, ge=0, description="Load4 meter reading [kWh]", examples=[None]
) )
measurement_max_loads: ClassVar[int] = 5 # Maximum number of loads that can be set max_loads: ClassVar[int] = 5 # Maximum number of loads that can be set
measurement_grid_export_mr: Optional[float] = Field( grid_export_mr: Optional[float] = Field(
default=None, ge=0, description="Export to grid meter reading [kWh]" default=None, ge=0, description="Export to grid meter reading [kWh]", examples=[1000]
) )
measurement_grid_import_mr: Optional[float] = Field( grid_import_mr: Optional[float] = Field(
default=None, ge=0, description="Import from grid meter reading [kWh]" default=None, ge=0, description="Import from grid meter reading [kWh]", examples=[1000]
) )
# Computed fields # Computed fields
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def measurement_loads(self) -> List[str]: def loads(self) -> List[str]:
"""Compute a list of active loads.""" """Compute a list of active loads."""
active_loads = [] active_loads = []
# Loop through measurement_loadx # Loop through loadx
for i in range(self.measurement_max_loads): for i in range(self.max_loads):
load_attr = f"measurement_load{i}_mr" load_attr = f"load{i}_mr"
# Check if either attribute is set and add to active loads # Check if either attribute is set and add to active loads
if getattr(self, load_attr, None): if getattr(self, load_attr, None):
@@ -103,9 +103,14 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
) )
topics: ClassVar[List[str]] = [ topics: ClassVar[List[str]] = [
"measurement_load", "load",
] ]
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def _interval_count( def _interval_count(
self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration
) -> int: ) -> int:
@@ -143,11 +148,16 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
if topic not in self.topics: if topic not in self.topics:
return None return None
topic_keys = [key for key in self.config.config_keys if key.startswith(topic)] topic_keys = [
key for key in self.config.measurement.model_fields.keys() if key.startswith(topic)
]
key = None key = None
if topic == "measurement_load": if topic == "load":
for config_key in topic_keys: for config_key in topic_keys:
if config_key.endswith("_name") and getattr(self.config, config_key) == name: if (
config_key.endswith("_name")
and getattr(self.config.measurement, config_key) == name
):
key = topic + config_key[len(topic) : len(topic) + 1] + "_mr" key = topic + config_key[len(topic) : len(topic) + 1] + "_mr"
break break
@@ -243,9 +253,9 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
end_datetime = self[-1].date_time end_datetime = self[-1].date_time
size = self._interval_count(start_datetime, end_datetime, interval) size = self._interval_count(start_datetime, end_datetime, interval)
load_total_array = np.zeros(size) load_total_array = np.zeros(size)
# Loop through measurement_load<x>_mr # Loop through load<x>_mr
for i in range(self.record_class().measurement_max_loads): for i in range(self.record_class().max_loads):
key = f"measurement_load{i}_mr" key = f"load{i}_mr"
# Calculate load per interval # Calculate load per interval
load_array = self._energy_from_meter_readings( load_array = self._energy_from_meter_readings(
key=key, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval key=key, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval

View File

@@ -1,11 +1,10 @@
import logging
import random import random
import time import time
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import numpy as np import numpy as np
from deap import algorithms, base, creator, tools from deap import algorithms, base, creator, tools
from loguru import logger
from pydantic import Field, field_validator, model_validator from pydantic import Field, field_validator, model_validator
from typing_extensions import Self from typing_extensions import Self
@@ -14,8 +13,7 @@ from akkudoktoreos.core.coreabc import (
DevicesMixin, DevicesMixin,
EnergyManagementSystemMixin, EnergyManagementSystemMixin,
) )
from akkudoktoreos.core.ems import EnergieManagementSystemParameters, SimulationResult from akkudoktoreos.core.ems import EnergyManagementParameters, SimulationResult
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.devices.battery import ( from akkudoktoreos.devices.battery import (
Battery, Battery,
@@ -25,14 +23,11 @@ from akkudoktoreos.devices.battery import (
) )
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Inverter, InverterParameters from akkudoktoreos.devices.inverter import Inverter, InverterParameters
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
from akkudoktoreos.utils.utils import NumpyEncoder from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
class OptimizationParameters(ParametersBaseModel): class OptimizationParameters(ParametersBaseModel):
ems: EnergieManagementSystemParameters ems: EnergyManagementParameters
pv_akku: Optional[SolarPanelBatteryParameters] pv_akku: Optional[SolarPanelBatteryParameters]
inverter: Optional[InverterParameters] inverter: Optional[InverterParameters]
eauto: Optional[ElectricVehicleParameters] eauto: Optional[ElectricVehicleParameters]
@@ -112,8 +107,8 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
): ):
"""Initialize the optimization problem with the required parameters.""" """Initialize the optimization problem with the required parameters."""
self.opti_param: dict[str, Any] = {} self.opti_param: dict[str, Any] = {}
self.fixed_eauto_hours = self.config.prediction_hours - self.config.optimization_hours self.fixed_eauto_hours = self.config.prediction.hours - self.config.optimization.hours
self.possible_charge_values = self.config.optimization_ev_available_charge_rates_percent self.possible_charge_values = self.config.optimization.ev_available_charge_rates_percent
self.verbose = verbose self.verbose = verbose
self.fix_seed = fixed_seed self.fix_seed = fixed_seed
self.optimize_ev = True self.optimize_ev = True
@@ -123,8 +118,8 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
# Set a fixed seed for random operations if provided or in debug mode # Set a fixed seed for random operations if provided or in debug mode
if self.fix_seed is not None: if self.fix_seed is not None:
random.seed(self.fix_seed) random.seed(self.fix_seed)
elif logger.level == logging.DEBUG: elif logger.level == "DEBUG":
self.fix_seed = random.randint(1, 100000000000) self.fix_seed = random.randint(1, 100000000000) # noqa: S311
random.seed(self.fix_seed) random.seed(self.fix_seed)
def decode_charge_discharge( def decode_charge_discharge(
@@ -180,23 +175,23 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
total_states = 3 * len_ac total_states = 3 * len_ac
# 1. Mutating the charge_discharge part # 1. Mutating the charge_discharge part
charge_discharge_part = individual[: self.config.prediction_hours] charge_discharge_part = individual[: self.config.prediction.hours]
(charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part) (charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part)
# Instead of a fixed clamping to 0..8 or 0..6 dynamically: # Instead of a fixed clamping to 0..8 or 0..6 dynamically:
charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1) charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1)
individual[: self.config.prediction_hours] = charge_discharge_mutated individual[: self.config.prediction.hours] = charge_discharge_mutated
# 2. Mutating the EV charge part, if active # 2. Mutating the EV charge part, if active
if self.optimize_ev: if self.optimize_ev:
ev_charge_part = individual[ ev_charge_part = individual[
self.config.prediction_hours : self.config.prediction_hours * 2 self.config.prediction.hours : self.config.prediction.hours * 2
] ]
(ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part) (ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part)
ev_charge_part_mutated[self.config.prediction_hours - self.fixed_eauto_hours :] = [ ev_charge_part_mutated[self.config.prediction.hours - self.fixed_eauto_hours :] = [
0 0
] * self.fixed_eauto_hours ] * self.fixed_eauto_hours
individual[self.config.prediction_hours : self.config.prediction_hours * 2] = ( individual[self.config.prediction.hours : self.config.prediction.hours * 2] = (
ev_charge_part_mutated ev_charge_part_mutated
) )
@@ -212,13 +207,13 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
def create_individual(self) -> list[int]: def create_individual(self) -> list[int]:
# Start with discharge states for the individual # Start with discharge states for the individual
individual_components = [ individual_components = [
self.toolbox.attr_discharge_state() for _ in range(self.config.prediction_hours) self.toolbox.attr_discharge_state() for _ in range(self.config.prediction.hours)
] ]
# Add EV charge index values if optimize_ev is True # Add EV charge index values if optimize_ev is True
if self.optimize_ev: if self.optimize_ev:
individual_components += [ individual_components += [
self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction_hours) self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction.hours)
] ]
# Add the start time of the household appliance if it's being optimized # Add the start time of the household appliance if it's being optimized
@@ -251,7 +246,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
individual.extend(eautocharge_hours_index.tolist()) individual.extend(eautocharge_hours_index.tolist())
elif self.optimize_ev: elif self.optimize_ev:
# Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu # Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu
individual.extend([0] * self.config.prediction_hours) individual.extend([0] * self.config.prediction.hours)
# Add dishwasher start time if applicable # Add dishwasher start time if applicable
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None: if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None:
@@ -273,12 +268,13 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
3. Dishwasher start time (integer if applicable). 3. Dishwasher start time (integer if applicable).
""" """
# Discharge hours as a NumPy array of ints # Discharge hours as a NumPy array of ints
discharge_hours_bin = np.array(individual[: self.config.prediction_hours], dtype=int) discharge_hours_bin = np.array(individual[: self.config.prediction.hours], dtype=int)
# EV charge hours as a NumPy array of ints (if optimize_ev is True) # EV charge hours as a NumPy array of ints (if optimize_ev is True)
eautocharge_hours_index = ( eautocharge_hours_index = (
# append ev charging states to individual
np.array( np.array(
individual[self.config.prediction_hours : self.config.prediction_hours * 2], individual[self.config.prediction.hours : self.config.prediction.hours * 2],
dtype=int, dtype=int,
) )
if self.optimize_ev if self.optimize_ev
@@ -390,7 +386,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
) )
self.ems.set_ev_charge_hours(eautocharge_hours_float) self.ems.set_ev_charge_hours(eautocharge_hours_float)
else: else:
self.ems.set_ev_charge_hours(np.full(self.config.prediction_hours, 0)) self.ems.set_ev_charge_hours(np.full(self.config.prediction.hours, 0))
return self.ems.simulate(self.ems.start_datetime.hour) return self.ems.simulate(self.ems.start_datetime.hour)
@@ -452,7 +448,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
# min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size) # min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size)
# battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:] # battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:]
# discharge_hours_bin_tail = discharge_hours_bin[-min_length:] # discharge_hours_bin_tail = discharge_hours_bin[-min_length:]
# len_ac = len(self.config.optimization_ev_available_charge_rates_percent) # len_ac = len(self.config.optimization.ev_available_charge_rates_percent)
# # # Find hours where battery SoC is 0 # # # Find hours where battery SoC is 0
# # zero_soc_mask = battery_soc_per_hour_tail == 0 # # zero_soc_mask = battery_soc_per_hour_tail == 0
@@ -501,7 +497,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
if parameters.eauto and self.ems.ev if parameters.eauto and self.ems.ev
else 0 else 0
) )
* self.config.optimization_penalty, * self.config.optimization.penalty,
) )
return (gesamtbilanz,) return (gesamtbilanz,)
@@ -569,30 +565,26 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
start_hour = self.ems.start_datetime.hour start_hour = self.ems.start_datetime.hour
einspeiseverguetung_euro_pro_wh = np.full( einspeiseverguetung_euro_pro_wh = np.full(
self.config.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh self.config.prediction.hours, parameters.ems.einspeiseverguetung_euro_pro_wh
) )
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate # TODO: Refactor device setup phase out
sc = SelfConsumptionProbabilityInterpolator( self.devices.reset()
Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
)
# Initialize PV and EV batteries # Initialize PV and EV batteries
akku: Optional[Battery] = None akku: Optional[Battery] = None
if parameters.pv_akku: if parameters.pv_akku:
akku = Battery( akku = Battery(parameters.pv_akku)
parameters.pv_akku, self.devices.add_device(akku)
hours=self.config.prediction_hours, akku.set_charge_per_hour(np.full(self.config.prediction.hours, 1))
)
akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1))
eauto: Optional[Battery] = None eauto: Optional[Battery] = None
if parameters.eauto: if parameters.eauto:
eauto = Battery( eauto = Battery(
parameters.eauto, parameters.eauto,
hours=self.config.prediction_hours,
) )
eauto.set_charge_per_hour(np.full(self.config.prediction_hours, 1)) self.devices.add_device(eauto)
eauto.set_charge_per_hour(np.full(self.config.prediction.hours, 1))
self.optimize_ev = ( self.optimize_ev = (
parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0 parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0
) )
@@ -603,20 +595,22 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
dishwasher = ( dishwasher = (
HomeAppliance( HomeAppliance(
parameters=parameters.dishwasher, parameters=parameters.dishwasher,
hours=self.config.prediction_hours,
) )
if parameters.dishwasher is not None if parameters.dishwasher is not None
else None else None
) )
self.devices.add_device(dishwasher)
# Initialize the inverter and energy management system # Initialize the inverter and energy management system
inverter: Optional[Inverter] = None inverter: Optional[Inverter] = None
if parameters.inverter: if parameters.inverter:
inverter = Inverter( inverter = Inverter(
sc,
parameters.inverter, parameters.inverter,
akku,
) )
self.devices.add_device(inverter)
self.devices.post_setup()
self.ems.set_parameters( self.ems.set_parameters(
parameters.ems, parameters.ems,
inverter=inverter, inverter=inverter,

View File

@@ -3,27 +3,22 @@ from typing import List, Optional
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
class OptimizationCommonSettings(SettingsBaseModel): class OptimizationCommonSettings(SettingsBaseModel):
"""Base configuration for optimization settings. """General Optimization Configuration.
Attributes: Attributes:
optimization_hours (int): Number of hours for optimizations. hours (int): Number of hours for optimizations.
""" """
optimization_hours: Optional[int] = Field( hours: Optional[int] = Field(
default=24, ge=0, description="Number of hours into the future for optimizations." default=48, ge=0, description="Number of hours into the future for optimizations."
) )
optimization_penalty: Optional[int] = Field( penalty: Optional[int] = Field(default=10, description="Penalty factor used in optimization.")
default=10, description="Penalty factor used in optimization."
)
optimization_ev_available_charge_rates_percent: Optional[List[float]] = Field( ev_available_charge_rates_percent: Optional[List[float]] = Field(
default=[ default=[
0.0, 0.0,
6.0 / 16.0, 6.0 / 16.0,

View File

@@ -3,11 +3,8 @@
from pydantic import ConfigDict from pydantic import ConfigDict
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel from akkudoktoreos.core.pydantic import PydanticBaseModel
logger = get_logger(__name__)
class OptimizationBase(ConfigMixin, PredictionMixin, PydanticBaseModel): class OptimizationBase(ConfigMixin, PredictionMixin, PydanticBaseModel):
"""Base class for handling optimization data. """Base class for handling optimization data.

View File

@@ -1,14 +1,50 @@
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
from akkudoktoreos.prediction.prediction import get_prediction
prediction_eos = get_prediction()
# Valid elecprice providers
elecprice_providers = [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, ElecPriceProvider)
]
class ElecPriceCommonSettings(SettingsBaseModel): class ElecPriceCommonSettings(SettingsBaseModel):
elecprice_provider: Optional[str] = Field( """Electricity Price Prediction Configuration."""
default=None, description="Electricity price provider id of provider to be used."
provider: Optional[str] = Field(
default=None,
description="Electricity price provider id of provider to be used.",
examples=["ElecPriceAkkudoktor"],
) )
elecprice_charges_kwh: Optional[float] = Field( charges_kwh: Optional[float] = Field(
default=None, ge=0, description="Electricity price charges (€/kWh)." default=None, ge=0, description="Electricity price charges (€/kWh).", examples=[0.21]
) )
vat_rate: Optional[float] = Field(
default=1.19,
ge=0,
description="VAT rate factor applied to electricity price when charges are used.",
examples=[1.19],
)
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
)
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in elecprice_providers:
return value
raise ValueError(
f"Provider '{value}' is not a valid electricity price provider: {elecprice_providers}."
)

View File

@@ -9,11 +9,8 @@ from typing import List, Optional
from pydantic import Field, computed_field from pydantic import Field, computed_field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
logger = get_logger(__name__)
class ElecPriceDataRecord(PredictionRecord): class ElecPriceDataRecord(PredictionRecord):
"""Represents a electricity price data record containing various price attributes at a specific datetime. """Represents a electricity price data record containing various price attributes at a specific datetime.
@@ -49,15 +46,15 @@ class ElecPriceProvider(PredictionProvider):
electricity price_provider (str): Prediction provider for electricity price. electricity price_provider (str): Prediction provider for electricity price.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range, end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
# overload # overload
@@ -71,4 +68,4 @@ class ElecPriceProvider(PredictionProvider):
return "ElecPriceProvider" return "ElecPriceProvider"
def enabled(self) -> bool: def enabled(self) -> bool:
return self.provider_id() == self.config.elecprice_provider return self.provider_id() == self.config.elecprice.provider

View File

@@ -11,17 +11,15 @@ from typing import Any, List, Optional, Union
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import requests import requests
from loguru import logger
from pydantic import ValidationError from pydantic import ValidationError
from statsmodels.tsa.holtwinters import ExponentialSmoothing from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.pydantic import PydanticBaseModel from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.utils.cacheutil import cache_in_file
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
logger = get_logger(__name__)
class AkkudoktorElecPriceMeta(PydanticBaseModel): class AkkudoktorElecPriceMeta(PydanticBaseModel):
start_timestamp: str start_timestamp: str
@@ -54,11 +52,11 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
prediction_hours (int, optional): Number of hours in the future for the forecast. hours (int, optional): Number of hours in the future for the forecast.
prediction_historic_hours (int, optional): Number of past hours for retaining data. historic_hours (int, optional): Number of past hours for retaining data.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime. start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -104,17 +102,18 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
- add the file cache again. - add the file cache again.
""" """
source = "https://api.akkudoktor.net" source = "https://api.akkudoktor.net"
assert self.start_datetime # mypy fix if not self.start_datetime:
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
# Try to take data from 5 weeks back for prediction # Try to take data from 5 weeks back for prediction
date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD") date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}" url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.general.timezone}"
response = requests.get(url) response = requests.get(url, timeout=10)
logger.debug(f"Response from {url}: {response}") logger.debug(f"Response from {url}: {response}")
response.raise_for_status() # Raise an error for bad responses response.raise_for_status() # Raise an error for bad responses
akkudoktor_data = self._validate_data(response.content) akkudoktor_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return akkudoktor_data return akkudoktor_data
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray: def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
@@ -125,18 +124,16 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
capped_data = data.clip(min=lower_bound, max=upper_bound) capped_data = data.clip(min=lower_bound, max=upper_bound)
return capped_data return capped_data
def _predict_ets( def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
self, history: np.ndarray, seasonal_periods: int, prediction_hours: int
) -> np.ndarray:
clean_history = self._cap_outliers(history) clean_history = self._cap_outliers(history)
model = ExponentialSmoothing( model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit() ).fit()
return model.forecast(prediction_hours) return model.forecast(hours)
def _predict_median(self, history: np.ndarray, prediction_hours: int) -> np.ndarray: def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history) clean_history = self._cap_outliers(history)
return np.full(prediction_hours, np.median(clean_history)) return np.full(hours, np.median(clean_history))
def _update_data( def _update_data(
self, force_update: Optional[bool] = False self, force_update: Optional[bool] = False
@@ -150,19 +147,20 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
""" """
# Get Akkudoktor electricity price data # Get Akkudoktor electricity price data
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
assert self.start_datetime # mypy fix if not self.start_datetime:
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
# Assumption that all lists are the same length and are ordered chronologically # Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps. # in ascending order and have the same timestamps.
# Get elecprice_charges_kwh in wh # Get charges_kwh in wh
charges_wh = (self.config.elecprice_charges_kwh or 0) / 1000 charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
highest_orig_datetime = None # newest datetime from the api after that we want to update. highest_orig_datetime = None # newest datetime from the api after that we want to update.
series_data = pd.Series(dtype=float) # Initialize an empty series series_data = pd.Series(dtype=float) # Initialize an empty series
for value in akkudoktor_data.values: for value in akkudoktor_data.values:
orig_datetime = to_datetime(value.start, in_timezone=self.config.timezone) orig_datetime = to_datetime(value.start, in_timezone=self.config.general.timezone)
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime: if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
highest_orig_datetime = orig_datetime highest_orig_datetime = orig_datetime
@@ -180,30 +178,29 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
) )
amount_datasets = len(self.records) amount_datasets = len(self.records)
assert highest_orig_datetime # mypy fix if not highest_orig_datetime: # mypy fix
error_msg = f"Highest original datetime not available: {highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
needed_prediction_hours = int( needed_hours = int(
self.config.prediction_hours self.config.prediction.hours
- ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600) - ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
) )
if needed_prediction_hours <= 0: if needed_hours <= 0:
logger.warning( logger.warning(
f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}" f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}"
) # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records ) # this might keep data longer than self.start_datetime + self.config.prediction.hours in the records
return return
if amount_datasets > 800: # we do the full ets with seasons of 1 week if amount_datasets > 800: # we do the full ets with seasons of 1 week
prediction = self._predict_ets( prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
history, seasonal_periods=168, prediction_hours=needed_prediction_hours
)
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
prediction = self._predict_ets( prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
history, seasonal_periods=24, prediction_hours=needed_prediction_hours
)
elif amount_datasets > 0: # not enough data for ets, do median elif amount_datasets > 0: # not enough data for ets, do median
prediction = self._predict_median(history, prediction_hours=needed_prediction_hours) prediction = self._predict_median(history, hours=needed_hours)
else: else:
logger.error("No data available for prediction") logger.error("No data available for prediction")
raise ValueError("No data available") raise ValueError("No data available")

View File

@@ -0,0 +1,257 @@
"""Retrieves and processes electricity price forecast data from Energy-Charts.
This module provides classes and mappings to manage electricity price data obtained from the
Energy-Charts API, including support for various electricity price attributes such as temperature,
humidity, cloud cover, and solar irradiance. The data is mapped to the `ElecPriceDataRecord`
format, enabling consistent access to forecasted and historical electricity price attributes.
"""
from datetime import datetime
from typing import Any, List, Optional, Union
import numpy as np
import pandas as pd
import requests
from loguru import logger
from pydantic import ValidationError
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
class EnergyChartsElecPrice(PydanticBaseModel):
license_info: str
unix_seconds: List[int]
price: List[float]
unit: str
deprecated: bool
class ElecPriceEnergyCharts(ElecPriceProvider):
"""Fetch and process electricity price forecast data from Energy-Charts.
ElecPriceEnergyCharts is a singleton-based class that retrieves electricity price forecast data
from the Energy-Charts API and maps it to `ElecPriceDataRecord` fields, applying
any necessary scaling or unit corrections. It manages the forecast over a range
of hours into the future and retains historical data.
Attributes:
hours (int, optional): Number of hours in the future for the forecast.
historic_hours (int, optional): Number of past hours for retaining data.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
Methods:
provider_id(): Returns a unique identifier for the provider.
_request_forecast(): Fetches the forecast from the Energy-Charts API.
_update_data(): Processes and updates forecast data from Energy-Charts in ElecPriceDataRecord format.
"""
highest_orig_datetime: Optional[datetime] = None
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the Energy-Charts provider."""
return "ElecPriceEnergyCharts"
@classmethod
def _validate_data(cls, json_str: Union[bytes, Any]) -> EnergyChartsElecPrice:
"""Validate Energy-Charts Electricity Price forecast data."""
try:
energy_charts_data = EnergyChartsElecPrice.model_validate_json(json_str)
except ValidationError as e:
error_msg = ""
for error in e.errors():
field = " -> ".join(str(x) for x in error["loc"])
message = error["msg"]
error_type = error["type"]
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
logger.error(f"Energy-Charts schema change: {error_msg}")
raise ValueError(error_msg)
return energy_charts_data
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
"""Fetch electricity price forecast data from Energy-Charts API.
This method sends a request to Energy-Charts API to retrieve forecast data for a specified
date range. The response data is parsed and returned as JSON for further processing.
Returns:
dict: The parsed JSON response from Energy-Charts API containing forecast data.
Raises:
ValueError: If the API response does not include expected `electricity price` data.
"""
source = "https://api.energy-charts.info"
if start_date is None:
# Try to take data from 5 weeks back for prediction
start_date = to_datetime(
self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD"
)
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
url = f"{source}/price?bzn=DE-LU&start={start_date}&end={last_date}"
response = requests.get(url, timeout=30)
logger.debug(f"Response from {url}: {response}")
response.raise_for_status() # Raise an error for bad responses
energy_charts_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return energy_charts_data
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
# Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps.
# Get charges_kwh in wh
charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
# Initialize
highest_orig_datetime = None # newest datetime from the api after that we want to update.
series_data = pd.Series(dtype=float) # Initialize an empty series
# Iterate over timestamps and prices together
for unix_sec, price_eur_per_mwh in zip(
energy_charts_data.unix_seconds, energy_charts_data.price
):
orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone)
# Track the latest datetime
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
highest_orig_datetime = orig_datetime
# Convert EUR/MWh to EUR/Wh, apply charges and VAT if charges > 0
if charges_wh > 0:
vat_rate = self.config.elecprice.vat_rate or 1.19
price_wh = ((price_eur_per_mwh / 1_000_000) + charges_wh) * vat_rate
else:
price_wh = price_eur_per_mwh / 1_000_000
# Store in series
series_data.at[orig_datetime] = price_wh
return series_data
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean()
std = data.std()
lower_bound = mean - sigma * std
upper_bound = mean + sigma * std
capped_data = data.clip(min=lower_bound, max=upper_bound)
return capped_data
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit()
return model.forecast(hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history))
def _update_data(
self, force_update: Optional[bool] = False
) -> None: # tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Update forecast data in the ElecPriceDataRecord format.
Retrieves data from Energy-Charts, maps each Energy-Charts field to the corresponding
`ElecPriceDataRecord` and applies any necessary scaling.
The final mapped and processed data is inserted into the sequence as `ElecPriceDataRecord`.
"""
# New prices are available every day at 14:00
now = pd.Timestamp.now(tz=self.config.general.timezone)
midnight = now.normalize()
hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47
end = midnight + pd.Timedelta(hours=hours_ahead)
if not self.start_datetime:
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
# Determine if update is needed and how many days
past_days = 35
if self.highest_orig_datetime:
history_series = self.key_to_series(
key="elecprice_marketprice_wh", start_datetime=self.start_datetime
)
# If history lower, then start_datetime
if history_series.index.min() <= self.start_datetime:
past_days = 0
needs_update = end > self.highest_orig_datetime
else:
needs_update = True
if needs_update:
logger.info(
f"Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
)
# Set start_date try to take data from 5 weeks back for prediction
start_date = to_datetime(
self.start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD"
)
# Get Energy-Charts electricity price data
energy_charts_data = self._request_forecast(
start_date=start_date, force_update=force_update
) # type: ignore
# Parse and store data
series_data = self._parse_data(energy_charts_data)
self.highest_orig_datetime = series_data.index.max()
self.key_from_series("elecprice_marketprice_wh", series_data)
else:
logger.info(
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
)
# Generate history array for prediction
history = self.key_to_array(
key="elecprice_marketprice_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
)
amount_datasets = len(self.records)
if not self.highest_orig_datetime: # mypy fix
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
needed_hours = int(
self.config.prediction.hours
- ((self.highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
)
if needed_hours <= 0:
logger.warning(
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.start_datetime}"
) # this might keep data longer than self.start_datetime + self.config.prediction.hours in the records
return
if amount_datasets > 800: # we do the full ets with seasons of 1 week
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
elif amount_datasets > 0: # not enough data for ets, do median
prediction = self._predict_median(history, hours=needed_hours)
else:
logger.error("No data available for prediction")
raise ValueError("No data available")
# write predictions into the records, update if exist.
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
for i in range(len(prediction))
],
)
self.key_from_series("elecprice_marketprice_wh", prediction_series)

View File

@@ -9,34 +9,33 @@ format, enabling consistent access to forecasted and historical elecprice attrib
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
logger = get_logger(__name__)
class ElecPriceImportCommonSettings(SettingsBaseModel): class ElecPriceImportCommonSettings(SettingsBaseModel):
"""Common settings for elecprice data import from file or JSON String.""" """Common settings for elecprice data import from file or JSON String."""
elecpriceimport_file_path: Optional[Union[str, Path]] = Field( import_file_path: Optional[Union[str, Path]] = Field(
default=None, description="Path to the file to import elecprice data from." default=None,
description="Path to the file to import elecprice data from.",
examples=[None, "/path/to/prices.json"],
) )
elecpriceimport_json: Optional[str] = Field( import_json: Optional[str] = Field(
default=None, default=None,
description="JSON string, dictionary of electricity price forecast value lists.", description="JSON string, dictionary of electricity price forecast value lists.",
examples=['{"elecprice_marketprice_wh": [0.0003384, 0.0003318, 0.0003284]}'],
) )
# Validators # Validators
@field_validator("elecpriceimport_file_path", mode="after") @field_validator("import_file_path", mode="after")
@classmethod @classmethod
def validate_elecpriceimport_file_path( def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
cls, value: Optional[Union[str, Path]]
) -> Optional[Path]:
if value is None: if value is None:
return None return None
if isinstance(value, str): if isinstance(value, str):
@@ -62,7 +61,15 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
return "ElecPriceImport" return "ElecPriceImport"
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.elecpriceimport_file_path is not None: if self.config.elecprice.provider_settings is None:
self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice") logger.debug(f"{self.provider_id()} data update without provider settings.")
if self.config.elecpriceimport_json is not None: return
self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice") if self.config.elecprice.provider_settings.import_file_path:
self.import_from_file(
self.config.elecprice.provider_settings.import_file_path,
key_prefix="elecprice",
)
if self.config.elecprice.provider_settings.import_json:
self.import_from_json(
self.config.elecprice.provider_settings.import_json, key_prefix="elecprice"
)

View File

@@ -6,13 +6,15 @@ from pathlib import Path
import numpy as np import numpy as np
from scipy.interpolate import RegularGridInterpolator from scipy.interpolate import RegularGridInterpolator
from akkudoktoreos.core.coreabc import SingletonMixin
class SelfConsumptionProbabilityInterpolator: class SelfConsumptionProbabilityInterpolator:
def __init__(self, filepath: str | Path): def __init__(self, filepath: str | Path):
self.filepath = filepath self.filepath = filepath
# Load the RegularGridInterpolator # Load the RegularGridInterpolator
with open(self.filepath, "rb") as file: with open(self.filepath, "rb") as file:
self.interpolator: RegularGridInterpolator = pickle.load(file) self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301
@lru_cache(maxsize=128) @lru_cache(maxsize=128)
def generate_points( def generate_points(
@@ -67,5 +69,17 @@ class SelfConsumptionProbabilityInterpolator:
# return self_consumption_rate # return self_consumption_rate
# Test the function class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin):
# print(calculate_self_consumption(1000, 1200)) def __init__(self) -> None:
if hasattr(self, "_initialized"):
return
filename = Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
super().__init__(filename)
# Initialize the Energy Management System, it is a singleton.
eos_load_interpolator = EOSLoadInterpolator()
def get_eos_load_interpolator() -> EOSLoadInterpolator:
return eos_load_interpolator

View File

@@ -1,18 +1,43 @@
"""Load forecast module for load predictions.""" """Load forecast module for load predictions."""
from typing import Optional from typing import Optional, Union
from pydantic import Field from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
from akkudoktoreos.prediction.loadvrm import LoadVrmCommonSettings
from akkudoktoreos.prediction.prediction import get_prediction
logger = get_logger(__name__) prediction_eos = get_prediction()
# Valid load providers
load_providers = [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, LoadProvider)
]
class LoadCommonSettings(SettingsBaseModel): class LoadCommonSettings(SettingsBaseModel):
"""Common settings for loaod forecast providers.""" """Load Prediction Configuration."""
load_provider: Optional[str] = Field( provider: Optional[str] = Field(
default=None, description="Load provider id of provider to be used." default=None,
description="Load provider id of provider to be used.",
examples=["LoadAkkudoktor"],
) )
provider_settings: Optional[
Union[LoadAkkudoktorCommonSettings, LoadVrmCommonSettings, LoadImportCommonSettings]
] = Field(default=None, description="Provider settings", examples=[None])
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in load_providers:
return value
raise ValueError(f"Provider '{value}' is not a valid load provider: {load_providers}.")

View File

@@ -9,11 +9,8 @@ from typing import List, Optional
from pydantic import Field from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
logger = get_logger(__name__)
class LoadDataRecord(PredictionRecord): class LoadDataRecord(PredictionRecord):
"""Represents a load data record containing various load attributes at a specific datetime.""" """Represents a load data record containing various load attributes at a specific datetime."""
@@ -33,18 +30,18 @@ class LoadProvider(PredictionProvider):
LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created. LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
Configuration variables: Configuration variables:
load_provider (str): Prediction provider for load. provider (str): Prediction provider for load.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range, end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
# overload # overload
@@ -58,4 +55,4 @@ class LoadProvider(PredictionProvider):
return "LoadProvider" return "LoadProvider"
def enabled(self) -> bool: def enabled(self) -> bool:
return self.provider_id() == self.config.load_provider return self.provider_id() == self.config.load.provider

View File

@@ -3,21 +3,19 @@
from typing import Optional from typing import Optional
import numpy as np import numpy as np
from loguru import logger
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadabc import LoadProvider from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
logger = get_logger(__name__)
class LoadAkkudoktorCommonSettings(SettingsBaseModel): class LoadAkkudoktorCommonSettings(SettingsBaseModel):
"""Common settings for load data import from file.""" """Common settings for load data import from file."""
loadakkudoktor_year_energy: Optional[float] = Field( loadakkudoktor_year_energy: Optional[float] = Field(
default=None, description="Yearly energy consumption (kWh)." default=None, description="Yearly energy consumption (kWh).", examples=[40421]
) )
@@ -91,7 +89,9 @@ class LoadAkkudoktor(LoadProvider):
list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"])) list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"]))
) )
# Calculate values in W by relative profile data and yearly consumption given in kWh # Calculate values in W by relative profile data and yearly consumption given in kWh
data_year_energy = profile_data * self.config.loadakkudoktor_year_energy * 1000 data_year_energy = (
profile_data * self.config.load.provider_settings.loadakkudoktor_year_energy * 1000
)
except FileNotFoundError: except FileNotFoundError:
error_msg = f"Error: File {load_file} not found." error_msg = f"Error: File {load_file} not found."
logger.error(error_msg) logger.error(error_msg)
@@ -109,7 +109,7 @@ class LoadAkkudoktor(LoadProvider):
# We provide prediction starting at start of day, to be compatible to old system. # We provide prediction starting at start of day, to be compatible to old system.
# End date for prediction is prediction hours from now. # End date for prediction is prediction hours from now.
date = self.start_datetime.start_of("day") date = self.start_datetime.start_of("day")
end_date = self.start_datetime.add(hours=self.config.prediction_hours) end_date = self.start_datetime.add(hours=self.config.prediction.hours)
while compare_datetimes(date, end_date).lt: while compare_datetimes(date, end_date).lt:
# Extract mean (index 0) and standard deviation (index 1) for the given day and hour # Extract mean (index 0) and standard deviation (index 1) for the given day and hour
# Day indexing starts at 0, -1 because of that # Day indexing starts at 0, -1 because of that
@@ -120,11 +120,12 @@ class LoadAkkudoktor(LoadProvider):
} }
if date.day_of_week < 5: if date.day_of_week < 5:
# Monday to Friday (0..4) # Monday to Friday (0..4)
values["load_mean_adjusted"] = hourly_stats[0] + weekday_adjust[date.hour] value_adjusted = hourly_stats[0] + weekday_adjust[date.hour]
else: else:
# Saturday, Sunday (5, 6) # Saturday, Sunday (5, 6)
values["load_mean_adjusted"] = hourly_stats[0] + weekend_adjust[date.hour] value_adjusted = hourly_stats[0] + weekend_adjust[date.hour]
values["load_mean_adjusted"] = max(0, value_adjusted)
self.update_value(date, values) self.update_value(date, values)
date += to_duration("1 hour") date += to_duration("1 hour")
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)

View File

@@ -9,28 +9,30 @@ format, enabling consistent access to forecasted and historical load attributes.
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadabc import LoadProvider from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
logger = get_logger(__name__)
class LoadImportCommonSettings(SettingsBaseModel): class LoadImportCommonSettings(SettingsBaseModel):
"""Common settings for load data import from file or JSON string.""" """Common settings for load data import from file or JSON string."""
load_import_file_path: Optional[Union[str, Path]] = Field( import_file_path: Optional[Union[str, Path]] = Field(
default=None, description="Path to the file to import load data from." default=None,
description="Path to the file to import load data from.",
examples=[None, "/path/to/yearly_load.json"],
) )
load_import_json: Optional[str] = Field( import_json: Optional[str] = Field(
default=None, description="JSON string, dictionary of load forecast value lists." default=None,
description="JSON string, dictionary of load forecast value lists.",
examples=['{"load0_mean": [676.71, 876.19, 527.13]}'],
) )
# Validators # Validators
@field_validator("load_import_file_path", mode="after") @field_validator("import_file_path", mode="after")
@classmethod @classmethod
def validate_loadimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]: def validate_loadimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
if value is None: if value is None:
@@ -58,7 +60,10 @@ class LoadImport(LoadProvider, PredictionImportProvider):
return "LoadImport" return "LoadImport"
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.load_import_file_path is not None: if self.config.load.provider_settings is None:
self.import_from_file(self.config.load_import_file_path, key_prefix="load") logger.debug(f"{self.provider_id()} data update without provider settings.")
if self.config.load_import_json is not None: return
self.import_from_json(self.config.load_import_json, key_prefix="load") if self.config.load.provider_settings.import_file_path:
self.import_from_file(self.config.provider_settings.import_file_path, key_prefix="load")
if self.config.load.provider_settings.import_json:
self.import_from_json(self.config.load.provider_settings.import_json, key_prefix="load")

View File

@@ -0,0 +1,109 @@
"""Retrieves load forecast data from VRM API."""
from typing import Any, Optional, Union
import requests
from loguru import logger
from pendulum import DateTime
from pydantic import Field, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.utils.datetimeutil import to_datetime
class VrmForecastRecords(PydanticBaseModel):
vrm_consumption_fc: list[tuple[int, float]]
solar_yield_forecast: list[tuple[int, float]]
class VrmForecastResponse(PydanticBaseModel):
success: bool
records: VrmForecastRecords
totals: dict
class LoadVrmCommonSettings(SettingsBaseModel):
"""Common settings for VRM API."""
load_vrm_token: str = Field(
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
)
load_vrm_idsite: int = Field(default=12345, description="VRM-Installation-ID", examples=[12345])
class LoadVrm(LoadProvider):
"""Fetch Load forecast data from VRM API."""
@classmethod
def provider_id(cls) -> str:
return "LoadVrm"
@classmethod
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
"""Validate the VRM API load forecast response."""
try:
return VrmForecastResponse.model_validate_json(json_str)
except ValidationError as e:
error_msg = "\n".join(
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
f"Error: {err['msg']}\nType: {err['type']}"
for err in e.errors()
)
logger.error(f"VRM-API schema validation failed:\n{error_msg}")
raise ValueError(error_msg)
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
"""Fetch forecast data from Victron VRM API."""
base_url = "https://vrmapi.victronenergy.com/v2/installations"
installation_id = self.config.load.provider_settings.load_vrm_idsite
api_token = self.config.load.provider_settings.load_vrm_token
url = f"{base_url}/{installation_id}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
logger.debug(f"Requesting VRM load forecast: {url}")
try:
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
except requests.RequestException as e:
logger.error(f"Error during VRM API request: {e}")
raise RuntimeError("Failed to fetch load forecast from VRM API") from e
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return self._validate_data(response.content)
def _ts_to_datetime(self, timestamp: int) -> DateTime:
"""Convert UNIX ms timestamp to timezone-aware datetime."""
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Fetch and store VRM load forecast as load_mean and related values."""
start_date = self.start_datetime.start_of("day")
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
start_ts = int(start_date.timestamp())
end_ts = int(end_date.timestamp())
logger.info(f"Updating Load forecast from VRM: {start_date} to {end_date}")
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
load_mean_data = []
for timestamp, value in vrm_forecast_data.records.vrm_consumption_fc:
date = self._ts_to_datetime(timestamp)
rounded_value = round(value, 2)
self.update_value(
date,
{"load_mean": rounded_value, "load_std": 0.0, "load_mean_adjusted": rounded_value},
)
load_mean_data.append((date, rounded_value))
logger.debug(f"Updated load_mean with {len(load_mean_data)} entries.")
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
if __name__ == "__main__":
lv = LoadVrm()
lv._update_data()

View File

@@ -28,78 +28,50 @@ Attributes:
from typing import List, Optional, Union from typing import List, Optional, Union
from pydantic import Field, computed_field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
from akkudoktoreos.prediction.loadimport import LoadImport from akkudoktoreos.prediction.loadimport import LoadImport
from akkudoktoreos.prediction.loadvrm import LoadVrm
from akkudoktoreos.prediction.predictionabc import PredictionContainer from akkudoktoreos.prediction.predictionabc import PredictionContainer
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport from akkudoktoreos.prediction.weatherimport import WeatherImport
from akkudoktoreos.utils.datetimeutil import to_timezone
class PredictionCommonSettings(SettingsBaseModel): class PredictionCommonSettings(SettingsBaseModel):
"""Base configuration for prediction settings, including forecast duration, geographic location, and time zone. """General Prediction Configuration.
This class provides configuration for prediction settings, allowing users to specify This class provides configuration for prediction settings, allowing users to specify
parameters such as the forecast duration (in hours) and location (latitude and longitude). parameters such as the forecast duration (in hours).
Validators ensure each parameter is within a specified range. A computed property, `timezone`, Validators ensure each parameter is within a specified range.
determines the time zone based on latitude and longitude.
Attributes: Attributes:
prediction_hours (Optional[int]): Number of hours into the future for predictions. hours (Optional[int]): Number of hours into the future for predictions.
Must be non-negative. Must be non-negative.
prediction_historic_hours (Optional[int]): Number of hours into the past for historical data. historic_hours (Optional[int]): Number of hours into the past for historical data.
Must be non-negative. Must be non-negative.
latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.
longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.
Properties:
timezone (Optional[str]): Computed time zone string based on the specified latitude
and longitude.
Validators: Validators:
validate_prediction_hours (int): Ensures `prediction_hours` is a non-negative integer. validate_hours (int): Ensures `hours` is a non-negative integer.
validate_prediction_historic_hours (int): Ensures `prediction_historic_hours` is a non-negative integer. validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.
validate_latitude (float): Ensures `latitude` is within the range -90 to 90.
validate_longitude (float): Ensures `longitude` is within the range -180 to 180.
""" """
prediction_hours: Optional[int] = Field( hours: Optional[int] = Field(
default=48, ge=0, description="Number of hours into the future for predictions" default=48, ge=0, description="Number of hours into the future for predictions"
) )
prediction_historic_hours: Optional[int] = Field( historic_hours: Optional[int] = Field(
default=48, default=48,
ge=0, ge=0,
description="Number of hours into the past for historical predictions data", description="Number of hours into the past for historical predictions data",
) )
latitude: Optional[float] = Field(
default=None,
ge=-90.0,
le=90.0,
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
)
longitude: Optional[float] = Field(
default=None,
ge=-180.0,
le=180.0,
description="Longitude in decimal degrees, within -180 to 180 (°)",
)
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def timezone(self) -> Optional[str]:
"""Compute timezone based on latitude and longitude."""
if self.latitude and self.longitude:
return to_timezone(location=(self.latitude, self.longitude), as_string=True)
return None
class Prediction(PredictionContainer): class Prediction(PredictionContainer):
@@ -114,10 +86,13 @@ class Prediction(PredictionContainer):
providers: List[ providers: List[
Union[ Union[
ElecPriceAkkudoktor, ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceImport, ElecPriceImport,
LoadAkkudoktor, LoadAkkudoktor,
LoadVrm,
LoadImport, LoadImport,
PVForecastAkkudoktor, PVForecastAkkudoktor,
PVForecastVrm,
PVForecastImport, PVForecastImport,
WeatherBrightSky, WeatherBrightSky,
WeatherClearOutside, WeatherClearOutside,
@@ -128,10 +103,13 @@ class Prediction(PredictionContainer):
# Initialize forecast providers, all are singletons. # Initialize forecast providers, all are singletons.
elecprice_akkudoktor = ElecPriceAkkudoktor() elecprice_akkudoktor = ElecPriceAkkudoktor()
elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_import = ElecPriceImport() elecprice_import = ElecPriceImport()
load_akkudoktor = LoadAkkudoktor() load_akkudoktor = LoadAkkudoktor()
load_vrm = LoadVrm()
load_import = LoadImport() load_import = LoadImport()
pvforecast_akkudoktor = PVForecastAkkudoktor() pvforecast_akkudoktor = PVForecastAkkudoktor()
pvforecast_vrm = PVForecastVrm()
pvforecast_import = PVForecastImport() pvforecast_import = PVForecastImport()
weather_brightsky = WeatherBrightSky() weather_brightsky = WeatherBrightSky()
weather_clearoutside = WeatherClearOutside() weather_clearoutside = WeatherClearOutside()
@@ -145,10 +123,13 @@ def get_prediction() -> Prediction:
prediction = Prediction( prediction = Prediction(
providers=[ providers=[
elecprice_akkudoktor, elecprice_akkudoktor,
elecprice_energy_charts,
elecprice_import, elecprice_import,
load_akkudoktor, load_akkudoktor,
load_vrm,
load_import, load_import,
pvforecast_akkudoktor, pvforecast_akkudoktor,
pvforecast_vrm,
pvforecast_import, pvforecast_import,
weather_brightsky, weather_brightsky,
weather_clearoutside, weather_clearoutside,

View File

@@ -10,6 +10,7 @@ and manipulation of configuration and prediction data in a clear, scalable, and
from typing import List, Optional from typing import List, Optional
from loguru import logger
from pendulum import DateTime from pendulum import DateTime
from pydantic import Field, computed_field from pydantic import Field, computed_field
@@ -22,11 +23,8 @@ from akkudoktoreos.core.dataabc import (
DataRecord, DataRecord,
DataSequence, DataSequence,
) )
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.utils.datetimeutil import to_duration from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__)
class PredictionBase(DataBase, MeasurementMixin): class PredictionBase(DataBase, MeasurementMixin):
"""Base class for handling prediction data. """Base class for handling prediction data.
@@ -114,16 +112,16 @@ class PredictionStartEndKeepMixin(PredictionBase):
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def end_datetime(self) -> Optional[DateTime]: def end_datetime(self) -> Optional[DateTime]:
"""Compute the end datetime based on the `start_datetime` and `prediction_hours`. """Compute the end datetime based on the `start_datetime` and `hours`.
Ajusts the calculated end time if DST transitions occur within the prediction window. Ajusts the calculated end time if DST transitions occur within the prediction window.
Returns: Returns:
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing. Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
""" """
if self.start_datetime and self.config.prediction_hours: if self.start_datetime and self.config.prediction.hours:
end_datetime = self.start_datetime + to_duration( end_datetime = self.start_datetime + to_duration(
f"{self.config.prediction_hours} hours" f"{self.config.prediction.hours} hours"
) )
dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours
logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}") logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}")
@@ -147,10 +145,10 @@ class PredictionStartEndKeepMixin(PredictionBase):
return None return None
historic_hours = self.historic_hours_min() historic_hours = self.historic_hours_min()
if ( if (
self.config.prediction_historic_hours self.config.prediction.historic_hours
and self.config.prediction_historic_hours > historic_hours and self.config.prediction.historic_hours > historic_hours
): ):
historic_hours = int(self.config.prediction_historic_hours) historic_hours = int(self.config.prediction.historic_hours)
return self.start_datetime - to_duration(f"{historic_hours} hours") return self.start_datetime - to_duration(f"{historic_hours} hours")
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@@ -206,9 +204,6 @@ class PredictionProvider(PredictionStartEndKeepMixin, DataProvider):
force_enable (bool, optional): If True, forces the update even if the provider is disabled. force_enable (bool, optional): If True, forces the update even if the provider is disabled.
force_update (bool, optional): If True, forces the provider to update the data even if still cached. force_update (bool, optional): If True, forces the provider to update the data even if still cached.
""" """
# Update prediction configuration
self.config.update()
# Check after configuration is updated. # Check after configuration is updated.
if not force_enable and not self.enabled(): if not force_enable and not self.enabled():
return return

View File

@@ -1,469 +1,248 @@
"""PV forecast module for PV power predictions.""" """PV forecast module for PV power predictions."""
from typing import Any, ClassVar, List, Optional from typing import Any, List, Optional, Self, Union
from pydantic import Field, computed_field from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.prediction.prediction import get_prediction
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
from akkudoktoreos.prediction.pvforecastvrm import PVforecastVrmCommonSettings
from akkudoktoreos.utils.docs import get_model_structure_from_examples
logger = get_logger(__name__) prediction_eos = get_prediction()
# Valid PV forecast providers
pvforecast_providers = [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, PVForecastProvider)
]
class PVForecastPlaneSetting(SettingsBaseModel):
"""PV Forecast Plane Configuration."""
# latitude: Optional[float] = Field(default=None, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)")
surface_tilt: Optional[float] = Field(
default=30.0,
ge=0.0,
le=90.0,
description="Tilt angle from horizontal plane. Ignored for two-axis tracking.",
examples=[10.0, 20.0],
)
surface_azimuth: Optional[float] = Field(
default=180.0,
ge=0.0,
le=360.0,
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
examples=[180.0, 90.0],
)
userhorizon: Optional[List[float]] = Field(
default=None,
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
examples=[[10.0, 20.0, 30.0], [5.0, 15.0, 25.0]],
)
peakpower: Optional[float] = Field(
default=None, description="Nominal power of PV system in kW.", examples=[5.0, 3.5]
)
pvtechchoice: Optional[str] = Field(
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
mountingplace: Optional[str] = Field(
default="free",
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
)
loss: Optional[float] = Field(default=14.0, description="Sum of PV system losses in percent")
trackingtype: Optional[int] = Field(
default=None,
ge=0,
le=5,
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
examples=[0, 1, 2, 3, 4, 5],
)
optimal_surface_tilt: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
examples=[False],
)
optimalangles: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
examples=[False],
)
albedo: Optional[float] = Field(
default=None,
description="Proportion of the light hitting the ground that it reflects back.",
examples=[None],
)
module_model: Optional[str] = Field(
default=None, description="Model of the PV modules of this plane.", examples=[None]
)
inverter_model: Optional[str] = Field(
default=None, description="Model of the inverter of this plane.", examples=[None]
)
inverter_paco: Optional[int] = Field(
default=None, description="AC power rating of the inverter [W].", examples=[6000, 4000]
)
modules_per_string: Optional[int] = Field(
default=None,
description="Number of the PV modules of the strings of this plane.",
examples=[20],
)
strings_per_inverter: Optional[int] = Field(
default=None,
description="Number of the strings of the inverter of this plane.",
examples=[2],
)
@model_validator(mode="after")
def validate_list_length(self) -> Self:
# Check if either attribute is set and add to active planes
if self.trackingtype == 2:
# Tilt angle from horizontal plane is ignored for two-axis tracking.
if self.surface_azimuth is None:
raise ValueError("If trackingtype is set, azimuth must be set as well.")
elif self.surface_tilt is None or self.surface_azimuth is None:
raise ValueError("surface_tilt and surface_azimuth must be set.")
return self
@field_validator("mountingplace")
def validate_mountingplace(cls, mountingplace: Optional[str]) -> Optional[str]:
if mountingplace is not None and mountingplace not in ["free", "building"]:
raise ValueError(f"Invalid mountingplace: {mountingplace}")
return mountingplace
@field_validator("pvtechchoice")
def validate_pvtechchoice(cls, pvtechchoice: Optional[str]) -> Optional[str]:
if pvtechchoice is not None and pvtechchoice not in ["crystSi", "CIS", "CdTe", "Unknown"]:
raise ValueError(f"Invalid pvtechchoice: {pvtechchoice}")
return pvtechchoice
class PVForecastCommonSettings(SettingsBaseModel): class PVForecastCommonSettings(SettingsBaseModel):
"""PV Forecast Configuration."""
# General plane parameters # General plane parameters
# https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html # https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html
# Inverter Parameters # Inverter Parameters
# https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/inverter.html # https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/inverter.html
pvforecast_provider: Optional[str] = Field( provider: Optional[str] = Field(
default=None, description="PVForecast provider id of provider to be used."
)
# pvforecast0_latitude: Optional[float] = Field(default=None, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)")
# Plane 0
pvforecast0_surface_tilt: Optional[float] = Field(
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
)
pvforecast0_surface_azimuth: Optional[float] = Field(
default=None, default=None,
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).", description="PVForecast provider id of provider to be used.",
) examples=["PVForecastAkkudoktor"],
pvforecast0_userhorizon: Optional[List[float]] = Field(
default=None,
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
)
pvforecast0_peakpower: Optional[float] = Field(
default=None, description="Nominal power of PV system in kW."
)
pvforecast0_pvtechchoice: Optional[str] = Field(
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
pvforecast0_mountingplace: Optional[str] = Field(
default="free",
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
)
pvforecast0_loss: Optional[float] = Field(
default=14.0, description="Sum of PV system losses in percent"
)
pvforecast0_trackingtype: Optional[int] = Field(
default=None,
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
)
pvforecast0_optimal_surface_tilt: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
)
pvforecast0_optimalangles: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
)
pvforecast0_albedo: Optional[float] = Field(
default=None,
description="Proportion of the light hitting the ground that it reflects back.",
)
pvforecast0_module_model: Optional[str] = Field(
default=None, description="Model of the PV modules of this plane."
)
pvforecast0_inverter_model: Optional[str] = Field(
default=None, description="Model of the inverter of this plane."
)
pvforecast0_inverter_paco: Optional[int] = Field(
default=None, description="AC power rating of the inverter. [W]"
)
pvforecast0_modules_per_string: Optional[int] = Field(
default=None, description="Number of the PV modules of the strings of this plane."
)
pvforecast0_strings_per_inverter: Optional[int] = Field(
default=None, description="Number of the strings of the inverter of this plane."
)
# Plane 1
pvforecast1_surface_tilt: Optional[float] = Field(
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
)
pvforecast1_surface_azimuth: Optional[float] = Field(
default=None,
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
)
pvforecast1_userhorizon: Optional[List[float]] = Field(
default=None,
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
)
pvforecast1_peakpower: Optional[float] = Field(
default=None, description="Nominal power of PV system in kW."
)
pvforecast1_pvtechchoice: Optional[str] = Field(
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
pvforecast1_mountingplace: Optional[str] = Field(
default="free",
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
)
pvforecast1_loss: Optional[float] = Field(
default=14.0, description="Sum of PV system losses in percent"
)
pvforecast1_trackingtype: Optional[int] = Field(
default=None,
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
)
pvforecast1_optimal_surface_tilt: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
)
pvforecast1_optimalangles: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
)
pvforecast1_albedo: Optional[float] = Field(
default=None,
description="Proportion of the light hitting the ground that it reflects back.",
)
pvforecast1_module_model: Optional[str] = Field(
default=None, description="Model of the PV modules of this plane."
)
pvforecast1_inverter_model: Optional[str] = Field(
default=None, description="Model of the inverter of this plane."
)
pvforecast1_inverter_paco: Optional[int] = Field(
default=None, description="AC power rating of the inverter. [W]"
)
pvforecast1_modules_per_string: Optional[int] = Field(
default=None, description="Number of the PV modules of the strings of this plane."
)
pvforecast1_strings_per_inverter: Optional[int] = Field(
default=None, description="Number of the strings of the inverter of this plane."
)
# Plane 2
pvforecast2_surface_tilt: Optional[float] = Field(
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
)
pvforecast2_surface_azimuth: Optional[float] = Field(
default=None,
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
)
pvforecast2_userhorizon: Optional[List[float]] = Field(
default=None,
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
)
pvforecast2_peakpower: Optional[float] = Field(
default=None, description="Nominal power of PV system in kW."
)
pvforecast2_pvtechchoice: Optional[str] = Field(
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
pvforecast2_mountingplace: Optional[str] = Field(
default="free",
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
)
pvforecast2_loss: Optional[float] = Field(
default=14.0, description="Sum of PV system losses in percent"
)
pvforecast2_trackingtype: Optional[int] = Field(
default=None,
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
)
pvforecast2_optimal_surface_tilt: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
)
pvforecast2_optimalangles: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
)
pvforecast2_albedo: Optional[float] = Field(
default=None,
description="Proportion of the light hitting the ground that it reflects back.",
)
pvforecast2_module_model: Optional[str] = Field(
default=None, description="Model of the PV modules of this plane."
)
pvforecast2_inverter_model: Optional[str] = Field(
default=None, description="Model of the inverter of this plane."
)
pvforecast2_inverter_paco: Optional[int] = Field(
default=None, description="AC power rating of the inverter. [W]"
)
pvforecast2_modules_per_string: Optional[int] = Field(
default=None, description="Number of the PV modules of the strings of this plane."
)
pvforecast2_strings_per_inverter: Optional[int] = Field(
default=None, description="Number of the strings of the inverter of this plane."
)
# Plane 3
pvforecast3_surface_tilt: Optional[float] = Field(
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
)
pvforecast3_surface_azimuth: Optional[float] = Field(
default=None,
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
)
pvforecast3_userhorizon: Optional[List[float]] = Field(
default=None,
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
)
pvforecast3_peakpower: Optional[float] = Field(
default=None, description="Nominal power of PV system in kW."
)
pvforecast3_pvtechchoice: Optional[str] = Field(
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
pvforecast3_mountingplace: Optional[str] = Field(
default="free",
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
)
pvforecast3_loss: Optional[float] = Field(
default=14.0, description="Sum of PV system losses in percent"
)
pvforecast3_trackingtype: Optional[int] = Field(
default=None,
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
)
pvforecast3_optimal_surface_tilt: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
)
pvforecast3_optimalangles: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
)
pvforecast3_albedo: Optional[float] = Field(
default=None,
description="Proportion of the light hitting the ground that it reflects back.",
)
pvforecast3_module_model: Optional[str] = Field(
default=None, description="Model of the PV modules of this plane."
)
pvforecast3_inverter_model: Optional[str] = Field(
default=None, description="Model of the inverter of this plane."
)
pvforecast3_inverter_paco: Optional[int] = Field(
default=None, description="AC power rating of the inverter. [W]"
)
pvforecast3_modules_per_string: Optional[int] = Field(
default=None, description="Number of the PV modules of the strings of this plane."
)
pvforecast3_strings_per_inverter: Optional[int] = Field(
default=None, description="Number of the strings of the inverter of this plane."
)
# Plane 4
pvforecast4_surface_tilt: Optional[float] = Field(
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
)
pvforecast4_surface_azimuth: Optional[float] = Field(
default=None,
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
)
pvforecast4_userhorizon: Optional[List[float]] = Field(
default=None,
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
)
pvforecast4_peakpower: Optional[float] = Field(
default=None, description="Nominal power of PV system in kW."
)
pvforecast4_pvtechchoice: Optional[str] = Field(
"crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
pvforecast4_mountingplace: Optional[str] = Field(
default="free",
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
)
pvforecast4_loss: Optional[float] = Field(
default=14.0, description="Sum of PV system losses in percent"
)
pvforecast4_trackingtype: Optional[int] = Field(
default=None,
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
)
pvforecast4_optimal_surface_tilt: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
)
pvforecast4_optimalangles: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
)
pvforecast4_albedo: Optional[float] = Field(
default=None,
description="Proportion of the light hitting the ground that it reflects back.",
)
pvforecast4_module_model: Optional[str] = Field(
default=None, description="Model of the PV modules of this plane."
)
pvforecast4_inverter_model: Optional[str] = Field(
default=None, description="Model of the inverter of this plane."
)
pvforecast4_inverter_paco: Optional[int] = Field(
default=None, description="AC power rating of the inverter. [W]"
)
pvforecast4_modules_per_string: Optional[int] = Field(
default=None, description="Number of the PV modules of the strings of this plane."
)
pvforecast4_strings_per_inverter: Optional[int] = Field(
default=None, description="Number of the strings of the inverter of this plane."
)
# Plane 5
pvforecast5_surface_tilt: Optional[float] = Field(
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
)
pvforecast5_surface_azimuth: Optional[float] = Field(
default=None,
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
)
pvforecast5_userhorizon: Optional[List[float]] = Field(
default=None,
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
)
pvforecast5_peakpower: Optional[float] = Field(
default=None, description="Nominal power of PV system in kW."
)
pvforecast5_pvtechchoice: Optional[str] = Field(
"crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
)
pvforecast5_mountingplace: Optional[str] = Field(
default="free",
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
)
pvforecast5_loss: Optional[float] = Field(
default=14.0, description="Sum of PV system losses in percent"
)
pvforecast5_trackingtype: Optional[int] = Field(
default=None,
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
)
pvforecast5_optimal_surface_tilt: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
)
pvforecast5_optimalangles: Optional[bool] = Field(
default=False,
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
)
pvforecast5_albedo: Optional[float] = Field(
default=None,
description="Proportion of the light hitting the ground that it reflects back.",
)
pvforecast5_module_model: Optional[str] = Field(
default=None, description="Model of the PV modules of this plane."
)
pvforecast5_inverter_model: Optional[str] = Field(
default=None, description="Model of the inverter of this plane."
)
pvforecast5_inverter_paco: Optional[int] = Field(
default=None, description="AC power rating of the inverter. [W]"
)
pvforecast5_modules_per_string: Optional[int] = Field(
default=None, description="Number of the PV modules of the strings of this plane."
)
pvforecast5_strings_per_inverter: Optional[int] = Field(
default=None, description="Number of the strings of the inverter of this plane."
) )
pvforecast_max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set provider_settings: Optional[
Union[PVForecastImportCommonSettings, PVforecastVrmCommonSettings]
] = Field(default=None, description="Provider settings", examples=[None])
# Computed fields planes: Optional[list[PVForecastPlaneSetting]] = Field(
default=None,
description="Plane configuration.",
examples=[get_model_structure_from_examples(PVForecastPlaneSetting, True)],
)
max_planes: Optional[int] = Field(
default=0,
ge=0,
description="Maximum number of planes that can be set",
)
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in pvforecast_providers:
return value
raise ValueError(
f"Provider '{value}' is not a valid PV forecast provider: {pvforecast_providers}."
)
## Computed fields
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def pvforecast_planes(self) -> List[str]: def planes_peakpower(self) -> List[float]:
"""Compute a list of active planes."""
active_planes = []
# Loop through pvforecast0 to pvforecast4
for i in range(self.pvforecast_max_planes):
plane = f"pvforecast{i}"
tackingtype_attr = f"{plane}_trackingtype"
tilt_attr = f"{plane}_surface_tilt"
azimuth_attr = f"{plane}_surface_azimuth"
# Check if either attribute is set and add to active planes
if getattr(self, tackingtype_attr, None) == 2:
# Tilt angle from horizontal plane is gnored for two-axis tracking.
if getattr(self, azimuth_attr, None) is not None:
active_planes.append(f"pvforecast{i}")
elif getattr(self, tilt_attr, None) and getattr(self, azimuth_attr, None):
active_planes.append(f"pvforecast{i}")
return active_planes
@computed_field # type: ignore[prop-decorator]
@property
def pvforecast_planes_peakpower(self) -> List[float]:
"""Compute a list of the peak power per active planes.""" """Compute a list of the peak power per active planes."""
planes_peakpower = [] planes_peakpower = []
for plane in self.pvforecast_planes: if self.planes:
peakpower_attr = f"{plane}_peakpower" for plane in self.planes:
peakpower = getattr(self, peakpower_attr, None) peakpower = plane.peakpower
if peakpower is None: if peakpower is None:
# TODO calculate peak power from modules/strings # TODO calculate peak power from modules/strings
planes_peakpower.append(float(5000)) planes_peakpower.append(float(5000))
else: else:
planes_peakpower.append(float(peakpower)) planes_peakpower.append(float(peakpower))
return planes_peakpower return planes_peakpower
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def pvforecast_planes_azimuth(self) -> List[float]: def planes_azimuth(self) -> List[float]:
"""Compute a list of the azimuths per active planes.""" """Compute a list of the azimuths per active planes."""
planes_azimuth = [] planes_azimuth = []
for plane in self.pvforecast_planes: if self.planes:
azimuth_attr = f"{plane}_surface_azimuth" for plane in self.planes:
azimuth = getattr(self, azimuth_attr, None) azimuth = plane.surface_azimuth
if azimuth is None: if azimuth is None:
# TODO Use default # TODO Use default
planes_azimuth.append(float(180)) planes_azimuth.append(float(180))
else: else:
planes_azimuth.append(float(azimuth)) planes_azimuth.append(float(azimuth))
return planes_azimuth return planes_azimuth
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def pvforecast_planes_tilt(self) -> List[float]: def planes_tilt(self) -> List[float]:
"""Compute a list of the tilts per active planes.""" """Compute a list of the tilts per active planes."""
planes_tilt = [] planes_tilt = []
for plane in self.pvforecast_planes: if self.planes:
tilt_attr = f"{plane}_surface_tilt" for plane in self.planes:
tilt = getattr(self, tilt_attr, None) tilt = plane.surface_tilt
if tilt is None: if tilt is None:
# TODO Use default # TODO Use default
planes_tilt.append(float(30)) planes_tilt.append(float(30))
else: else:
planes_tilt.append(float(tilt)) planes_tilt.append(float(tilt))
return planes_tilt return planes_tilt
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def pvforecast_planes_userhorizon(self) -> Any: def planes_userhorizon(self) -> Any:
"""Compute a list of the user horizon per active planes.""" """Compute a list of the user horizon per active planes."""
planes_userhorizon = [] planes_userhorizon = []
for plane in self.pvforecast_planes: if self.planes:
userhorizon_attr = f"{plane}_userhorizon" for plane in self.planes:
userhorizon = getattr(self, userhorizon_attr, None) userhorizon = plane.userhorizon
if userhorizon is None: if userhorizon is None:
# TODO Use default # TODO Use default
planes_userhorizon.append([float(0), float(0)]) planes_userhorizon.append([float(0), float(0)])
else: else:
planes_userhorizon.append(userhorizon) planes_userhorizon.append(userhorizon)
return planes_userhorizon return planes_userhorizon
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def pvforecast_planes_inverter_paco(self) -> Any: def planes_inverter_paco(self) -> Any:
"""Compute a list of the maximum power rating of the inverter per active planes.""" """Compute a list of the maximum power rating of the inverter per active planes."""
planes_inverter_paco = [] planes_inverter_paco = []
for plane in self.pvforecast_planes: if self.planes:
inverter_paco_attr = f"{plane}_inverter_paco" for plane in self.planes:
inverter_paco = getattr(self, inverter_paco_attr, None) inverter_paco = plane.inverter_paco
if inverter_paco is None: if inverter_paco is None:
# TODO Use default - no clipping # TODO Use default - no clipping
planes_inverter_paco.append(25000.0) planes_inverter_paco.append(25000.0)
else: else:
planes_inverter_paco.append(float(inverter_paco)) planes_inverter_paco.append(float(inverter_paco))
return planes_inverter_paco return planes_inverter_paco

View File

@@ -7,13 +7,11 @@ Notes:
from abc import abstractmethod from abc import abstractmethod
from typing import List, Optional from typing import List, Optional
from loguru import logger
from pydantic import Field from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
logger = get_logger(__name__)
class PVForecastDataRecord(PredictionRecord): class PVForecastDataRecord(PredictionRecord):
"""Represents a pvforecast data record containing various pvforecast attributes at a specific datetime.""" """Represents a pvforecast data record containing various pvforecast attributes at a specific datetime."""
@@ -28,18 +26,18 @@ class PVForecastProvider(PredictionProvider):
PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created. PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
Configuration variables: Configuration variables:
pvforecast_provider (str): Prediction provider for pvforecast. provider (str): Prediction provider for pvforecast.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions (inlcusive), defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions (inlcusive), defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range (exclusive), end_datetime (datetime, computed): The datetime representing the end of the prediction range (exclusive),
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data (inclusive), calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data (inclusive), calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
# overload # overload
@@ -54,6 +52,6 @@ class PVForecastProvider(PredictionProvider):
def enabled(self) -> bool: def enabled(self) -> bool:
logger.debug( logger.debug(
f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast_provider}" f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.provider}"
) )
return self.provider_id() == self.config.pvforecast_provider return self.provider_id() == self.config.pvforecast.provider

View File

@@ -14,21 +14,33 @@ Classes:
Example: Example:
# Set up the configuration with necessary fields for URL generation # Set up the configuration with necessary fields for URL generation
settings_data = { settings_data = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24, "latitude": 52.52,
"latitude": 52.52, "longitude": 13.405,
"longitude": 13.405, },
"pvforecast_provider": "Akkudoktor", "prediction": {
"pvforecast0_peakpower": 5.0, "hours": 48,
"pvforecast0_surface_azimuth": -10, "historic_hours": 24,
"pvforecast0_surface_tilt": 7, },
"pvforecast0_userhorizon": [20, 27, 22, 20], "pvforecast": {
"pvforecast0_inverter_paco": 10000, "provider": "PVForecastAkkudoktor",
"pvforecast1_peakpower": 4.8, "planes": [
"pvforecast1_surface_azimuth": -90, {
"pvforecast1_surface_tilt": 7, "peakpower": 5.0,
"pvforecast1_userhorizon": [30, 30, 30, 50], "surface_azimuth": 170,
"pvforecast1_inverter_paco": 10000, "surface_tilt": 7,
"userhorizon": [20, 27, 22, 20],
"inverter_paco": 10000,
},
{
"peakpower": 4.8,
"surface_azimuth": 90,
"surface_tilt": 7,
"userhorizon": [30, 30, 30, 50],
"inverter_paco": 10000,
}
]
}
} }
# Create the config instance from the provided data # Create the config instance from the provided data
@@ -47,12 +59,12 @@ Example:
print(forecast.report_ac_power_and_measurement()) print(forecast.report_ac_power_and_measurement())
Attributes: Attributes:
prediction_hours (int): Number of hours into the future to forecast. Default is 48. hours (int): Number of hours into the future to forecast. Default is 48.
prediction_historic_hours (int): Number of past hours to retain for analysis. Default is 24. historic_hours (int): Number of past hours to retain for analysis. Default is 24.
latitude (float): Latitude for the forecast location. latitude (float): Latitude for the forecast location.
longitude (float): Longitude for the forecast location. longitude (float): Longitude for the forecast location.
start_datetime (datetime): Start time for the forecast, defaulting to current datetime. start_datetime (datetime): Start time for the forecast, defaulting to current datetime.
end_datetime (datetime): Computed end datetime based on `start_datetime` and `prediction_hours`. end_datetime (datetime): Computed end datetime based on `start_datetime` and `hours`.
keep_datetime (datetime): Computed threshold datetime for retaining historical data. keep_datetime (datetime): Computed threshold datetime for retaining historical data.
Methods: Methods:
@@ -66,24 +78,22 @@ Methods:
from typing import Any, List, Optional, Union from typing import Any, List, Optional, Union
import requests import requests
from pydantic import Field, ValidationError, computed_field from loguru import logger
from pydantic import Field, ValidationError, computed_field, field_validator
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.pydantic import PydanticBaseModel from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.pvforecastabc import ( from akkudoktoreos.prediction.pvforecastabc import (
PVForecastDataRecord, PVForecastDataRecord,
PVForecastProvider, PVForecastProvider,
) )
from akkudoktoreos.utils.cacheutil import cache_in_file
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
logger = get_logger(__name__)
class AkkudoktorForecastHorizon(PydanticBaseModel): class AkkudoktorForecastHorizon(PydanticBaseModel):
altitude: int altitude: int
azimuthFrom: int azimuthFrom: float
azimuthTo: int azimuthTo: float
class AkkudoktorForecastMeta(PydanticBaseModel): class AkkudoktorForecastMeta(PydanticBaseModel):
@@ -102,6 +112,30 @@ class AkkudoktorForecastMeta(PydanticBaseModel):
horizont: List[List[AkkudoktorForecastHorizon]] horizont: List[List[AkkudoktorForecastHorizon]]
horizontString: List[str] horizontString: List[str]
@field_validator("power", "azimuth", "tilt", "powerInverter", mode="before")
@classmethod
def ensure_list(cls, v: Any) -> List[int]:
return v if isinstance(v, list) else [v]
@field_validator("horizont", mode="before")
@classmethod
def normalize_horizont(cls, v: Any) -> List[List[AkkudoktorForecastHorizon]]:
if isinstance(v, list):
# Case: flat list of dicts
if v and isinstance(v[0], dict):
return [v]
# Already in correct nested form
if v and isinstance(v[0], list):
return v
return v
@field_validator("horizontString", mode="before")
@classmethod
def parse_horizont_string(cls, v: Any) -> List[str]:
if isinstance(v, str):
return [s.strip() for s in v.split(",")]
return v
class AkkudoktorForecastValue(PydanticBaseModel): class AkkudoktorForecastValue(PydanticBaseModel):
datetime: str datetime: str
@@ -159,13 +193,13 @@ class PVForecastAkkudoktor(PVForecastProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
prediction_hours (int, optional): Number of hours in the future for the forecast. hours (int, optional): Number of hours in the future for the forecast.
prediction_historic_hours (int, optional): Number of past hours for retaining data. historic_hours (int, optional): Number of past hours for retaining data.
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90. latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180. longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime. start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -203,19 +237,24 @@ class PVForecastAkkudoktor(PVForecastProvider):
"""Build akkudoktor.net API request URL.""" """Build akkudoktor.net API request URL."""
base_url = "https://api.akkudoktor.net/forecast" base_url = "https://api.akkudoktor.net/forecast"
query_params = [ query_params = [
f"lat={self.config.latitude}", f"lat={self.config.general.latitude}",
f"lon={self.config.longitude}", f"lon={self.config.general.longitude}",
] ]
for i in range(len(self.config.pvforecast_planes)): for i in range(len(self.config.pvforecast.planes)):
query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}") query_params.append(f"power={int(self.config.pvforecast.planes_peakpower[i] * 1000)}")
query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}") # EOS orientation of of pv modules in azimuth in degree:
query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}") # north=0, east=90, south=180, west=270
# Akkudoktor orientation of pv modules in azimuth in degree:
# north=+-180, east=-90, south=0, west=90
azimuth_akkudoktor = int(self.config.pvforecast.planes_azimuth[i]) - 180
query_params.append(f"azimuth={azimuth_akkudoktor}")
query_params.append(f"tilt={int(self.config.pvforecast.planes_tilt[i])}")
query_params.append( query_params.append(
f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}" f"powerInverter={int(self.config.pvforecast.planes_inverter_paco[i])}"
) )
horizon_values = ",".join( horizon_values = ",".join(
str(int(h)) for h in self.config.pvforecast_planes_userhorizon[i] str(round(h)) for h in self.config.pvforecast.planes_userhorizon[i]
) )
query_params.append(f"horizont={horizon_values}") query_params.append(f"horizont={horizon_values}")
@@ -226,7 +265,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
"cellCoEff=-0.36", "cellCoEff=-0.36",
"inverterEfficiency=0.8", "inverterEfficiency=0.8",
"albedo=0.25", "albedo=0.25",
f"timezone={self.config.timezone}", f"timezone={self.config.general.timezone}",
"hourly=relativehumidity_2m%2Cwindspeed_10m", "hourly=relativehumidity_2m%2Cwindspeed_10m",
] ]
) )
@@ -250,12 +289,12 @@ class PVForecastAkkudoktor(PVForecastProvider):
Raises: Raises:
ValueError: If the API response does not include expected `meta` data. ValueError: If the API response does not include expected `meta` data.
""" """
response = requests.get(self._url()) response = requests.get(self._url(), timeout=10)
response.raise_for_status() # Raise an error for bad responses response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {self._url()}: {response}") logger.debug(f"Response from {self._url()}: {response}")
akkudoktor_data = self._validate_data(response.content) akkudoktor_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
return akkudoktor_data return akkudoktor_data
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
@@ -265,7 +304,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
`PVForecastAkkudoktorDataRecord`. `PVForecastAkkudoktorDataRecord`.
""" """
# Assure we have something to request PV power for. # Assure we have something to request PV power for.
if not self.config.pvforecast_planes: if not self.config.pvforecast.planes:
# No planes for PV # No planes for PV
error_msg = "Requested PV forecast, but no planes configured." error_msg = "Requested PV forecast, but no planes configured."
logger.error(f"Configuration error: {error_msg}") logger.error(f"Configuration error: {error_msg}")
@@ -275,28 +314,29 @@ class PVForecastAkkudoktor(PVForecastProvider):
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
# Timezone of the PV system # Timezone of the PV system
if self.config.timezone != akkudoktor_data.meta.timezone: if self.config.general.timezone != akkudoktor_data.meta.timezone:
error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'." error_msg = f"Configured timezone '{self.config.general.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
logger.error(f"Akkudoktor schema change: {error_msg}") logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg) raise ValueError(error_msg)
# Assumption that all lists are the same length and are ordered chronologically # Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps. # in ascending order and have the same timestamps.
if len(akkudoktor_data.values[0]) < self.config.prediction_hours: if len(akkudoktor_data.values[0]) < self.config.prediction.hours:
# Expect one value set per prediction hour # Expect one value set per prediction hour
error_msg = ( error_msg = (
f"The forecast must cover at least {self.config.prediction_hours} hours, " f"The forecast must cover at least {self.config.prediction.hours} hours, "
f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data." f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data."
) )
logger.error(f"Akkudoktor schema change: {error_msg}") logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg) raise ValueError(error_msg)
assert self.start_datetime # mypy fix if not self.start_datetime:
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
# Iterate over forecast data points # Iterate over forecast data points
for forecast_values in zip(*akkudoktor_data.values): for forecast_values in zip(*akkudoktor_data.values):
original_datetime = forecast_values[0].datetime original_datetime = forecast_values[0].datetime
dt = to_datetime(original_datetime, in_timezone=self.config.timezone) dt = to_datetime(original_datetime, in_timezone=self.config.general.timezone)
# Skip outdated forecast data # Skip outdated forecast data
if compare_datetimes(dt, self.start_datetime.start_of("day")).lt: if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
@@ -314,9 +354,9 @@ class PVForecastAkkudoktor(PVForecastProvider):
self.update_value(dt, data) self.update_value(dt, data)
if len(self) < self.config.prediction_hours: if len(self) < self.config.prediction.hours:
raise ValueError( raise ValueError(
f"The forecast must cover at least {self.config.prediction_hours} hours, " f"The forecast must cover at least {self.config.prediction.hours} hours, "
f"but only {len(self)} hours starting from {self.start_datetime} " f"but only {len(self)} hours starting from {self.start_datetime} "
f"were predicted." f"were predicted."
) )
@@ -365,31 +405,47 @@ if __name__ == "__main__":
""" """
# Set up the configuration with necessary fields for URL generation # Set up the configuration with necessary fields for URL generation
settings_data = { settings_data = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24, "latitude": 52.52,
"latitude": 52.52, "longitude": 13.405,
"longitude": 13.405, },
"pvforecast_provider": "PVForecastAkkudoktor", "prediction": {
"pvforecast0_peakpower": 5.0, "hours": 48,
"pvforecast0_surface_azimuth": -10, "historic_hours": 24,
"pvforecast0_surface_tilt": 7, },
"pvforecast0_userhorizon": [20, 27, 22, 20], "pvforecast": {
"pvforecast0_inverter_paco": 10000, "provider": "PVForecastAkkudoktor",
"pvforecast1_peakpower": 4.8, "planes": [
"pvforecast1_surface_azimuth": -90, {
"pvforecast1_surface_tilt": 7, "peakpower": 5.0,
"pvforecast1_userhorizon": [30, 30, 30, 50], "surface_azimuth": 170,
"pvforecast1_inverter_paco": 10000, "surface_tilt": 7,
"pvforecast2_peakpower": 1.4, "userhorizon": [20, 27, 22, 20],
"pvforecast2_surface_azimuth": -40, "inverter_paco": 10000,
"pvforecast2_surface_tilt": 60, },
"pvforecast2_userhorizon": [60, 30, 0, 30], {
"pvforecast2_inverter_paco": 2000, "peakpower": 4.8,
"pvforecast3_peakpower": 1.6, "surface_azimuth": 90,
"pvforecast3_surface_azimuth": 5, "surface_tilt": 7,
"pvforecast3_surface_tilt": 45, "userhorizon": [30, 30, 30, 50],
"pvforecast3_userhorizon": [45, 25, 30, 60], "inverter_paco": 10000,
"pvforecast3_inverter_paco": 1400, },
{
"peakpower": 1.4,
"surface_azimuth": 140,
"surface_tilt": 60,
"userhorizon": [60, 30, 0, 30],
"inverter_paco": 2000,
},
{
"peakpower": 1.6,
"surface_azimuth": 185,
"surface_tilt": 45,
"userhorizon": [45, 25, 30, 60],
"inverter_paco": 1400,
},
],
},
} }
# Initialize the forecast object with the generated configuration # Initialize the forecast object with the generated configuration

View File

@@ -9,34 +9,33 @@ format, enabling consistent access to forecasted and historical pvforecast attri
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
logger = get_logger(__name__)
class PVForecastImportCommonSettings(SettingsBaseModel): class PVForecastImportCommonSettings(SettingsBaseModel):
"""Common settings for pvforecast data import from file or JSON string.""" """Common settings for pvforecast data import from file or JSON string."""
pvforecastimport_file_path: Optional[Union[str, Path]] = Field( import_file_path: Optional[Union[str, Path]] = Field(
default=None, description="Path to the file to import PV forecast data from." default=None,
description="Path to the file to import PV forecast data from.",
examples=[None, "/path/to/pvforecast.json"],
) )
pvforecastimport_json: Optional[str] = Field( import_json: Optional[str] = Field(
default=None, default=None,
description="JSON string, dictionary of PV forecast value lists.", description="JSON string, dictionary of PV forecast value lists.",
examples=['{"pvforecast_ac_power": [0, 8.05, 352.91]}'],
) )
# Validators # Validators
@field_validator("pvforecastimport_file_path", mode="after") @field_validator("import_file_path", mode="after")
@classmethod @classmethod
def validate_pvforecastimport_file_path( def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
cls, value: Optional[Union[str, Path]]
) -> Optional[Path]:
if value is None: if value is None:
return None return None
if isinstance(value, str): if isinstance(value, str):
@@ -62,7 +61,16 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
return "PVForecastImport" return "PVForecastImport"
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.pvforecastimport_file_path is not None: if self.config.pvforecast.provider_settings is None:
self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast") logger.debug(f"{self.provider_id()} data update without provider settings.")
if self.config.pvforecastimport_json is not None: return
self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast") if self.config.pvforecast.provider_settings.import_file_path is not None:
self.import_from_file(
self.config.pvforecast.provider_settings.import_file_path,
key_prefix="pvforecast",
)
if self.config.pvforecast.provider_settings.import_json is not None:
self.import_from_json(
self.config.pvforecast.provider_settings.import_json,
key_prefix="pvforecast",
)

View File

@@ -0,0 +1,110 @@
"""Retrieves pvforecast data from VRM API."""
from typing import Any, Optional, Union
import requests
from loguru import logger
from pendulum import DateTime
from pydantic import Field, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
from akkudoktoreos.utils.datetimeutil import to_datetime
class VrmForecastRecords(PydanticBaseModel):
vrm_consumption_fc: list[tuple[int, float]]
solar_yield_forecast: list[tuple[int, float]]
class VrmForecastResponse(PydanticBaseModel):
success: bool
records: VrmForecastRecords
totals: dict
class PVforecastVrmCommonSettings(SettingsBaseModel):
"""Common settings for VRM API."""
pvforecast_vrm_token: str = Field(
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
)
pvforecast_vrm_idsite: int = Field(
default=12345, description="VRM-Installation-ID", examples=[12345]
)
class PVForecastVrm(PVForecastProvider):
"""Fetch and process PV forecast data from VRM API."""
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the PV-Forecast-Provider."""
return "PVForecastVrm"
@classmethod
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
"""Validate the VRM forecast response data against the expected schema."""
try:
return VrmForecastResponse.model_validate_json(json_str)
except ValidationError as e:
error_msg = "\n".join(
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
f"Error: {err['msg']}\nType: {err['type']}"
for err in e.errors()
)
logger.error(f"VRM-API schema change:\n{error_msg}")
raise ValueError(error_msg)
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
"""Fetch forecast data from Victron VRM API."""
source = "https://vrmapi.victronenergy.com/v2/installations"
id_site = self.config.pvforecast.provider_settings.pvforecast_vrm_idsite
api_token = self.config.pvforecast.provider_settings.pvforecast_vrm_token
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
url = f"{source}/{id_site}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
logger.debug(f"Requesting VRM forecast: {url}")
try:
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
except requests.RequestException as e:
logger.error(f"Failed to fetch pvforecast: {e}")
raise RuntimeError("Failed to fetch pvforecast from VRM API") from e
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return self._validate_data(response.content)
def _ts_to_datetime(self, timestamp: int) -> DateTime:
"""Convert UNIX ms timestamp to timezone-aware datetime."""
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update forecast data in the PVForecastDataRecord format."""
start_date = self.start_datetime.start_of("day")
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
start_ts = int(start_date.timestamp())
end_ts = int(end_date.timestamp())
logger.info(f"Updating PV forecast from VRM: {start_date} to {end_date}")
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
pv_forecast = []
for timestamp, value in vrm_forecast_data.records.solar_yield_forecast:
date = self._ts_to_datetime(timestamp)
dc_power = round(value, 2)
ac_power = round(dc_power * 0.96, 2)
self.update_value(
date, {"pvforecast_dc_power": dc_power, "pvforecast_ac_power": ac_power}
)
pv_forecast.append((date, dc_power))
logger.debug(f"Updated pvforecast_dc_power with {len(pv_forecast)} entries.")
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
# Example usage
if __name__ == "__main__":
pv = PVForecastVrm()
pv._update_data()

View File

@@ -2,12 +2,42 @@
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.prediction import get_prediction
from akkudoktoreos.prediction.weatherabc import WeatherProvider
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
prediction_eos = get_prediction()
# Valid weather providers
weather_providers = [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, WeatherProvider)
]
class WeatherCommonSettings(SettingsBaseModel): class WeatherCommonSettings(SettingsBaseModel):
weather_provider: Optional[str] = Field( """Weather Forecast Configuration."""
default=None, description="Weather provider id of provider to be used."
provider: Optional[str] = Field(
default=None,
description="Weather provider id of provider to be used.",
examples=["WeatherImport"],
) )
provider_settings: Optional[WeatherImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
)
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in weather_providers:
return value
raise ValueError(
f"Provider '{value}' is not a valid weather provider: {weather_providers}."
)

View File

@@ -14,11 +14,8 @@ import pandas as pd
import pvlib import pvlib
from pydantic import Field from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
logger = get_logger(__name__)
class WeatherDataRecord(PredictionRecord): class WeatherDataRecord(PredictionRecord):
"""Represents a weather data record containing various weather attributes at a specific datetime. """Represents a weather data record containing various weather attributes at a specific datetime.
@@ -101,18 +98,18 @@ class WeatherProvider(PredictionProvider):
WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created. WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
Configuration variables: Configuration variables:
weather_provider (str): Prediction provider for weather. provider (str): Prediction provider for weather.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range, end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
# overload # overload
@@ -126,7 +123,7 @@ class WeatherProvider(PredictionProvider):
return "WeatherProvider" return "WeatherProvider"
def enabled(self) -> bool: def enabled(self) -> bool:
return self.provider_id() == self.config.weather_provider return self.provider_id() == self.config.weather.provider
@classmethod @classmethod
def estimate_irradiance_from_cloud_cover( def estimate_irradiance_from_cloud_cover(

View File

@@ -7,23 +7,21 @@ format, enabling consistent access to forecasted and historical weather attribut
""" """
import json import json
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple, Union
import numpy as np
import pandas as pd import pandas as pd
import pvlib import pvlib
import requests import requests
from loguru import logger
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
from akkudoktoreos.utils.cacheutil import cache_in_file from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
from akkudoktoreos.utils.datetimeutil import to_datetime
logger = get_logger(__name__) WheaterDataBrightSkyMapping: List[Tuple[str, Optional[str], Optional[Union[str, float]]]] = [
WheaterDataBrightSkyMapping: List[Tuple[str, Optional[str], Optional[float]]] = [
# brightsky_key, description, corr_factor # brightsky_key, description, corr_factor
("timestamp", "DateTime", None), ("timestamp", "DateTime", "to datetime in timezone"),
("precipitation", "Precipitation Amount (mm)", 1), ("precipitation", "Precipitation Amount (mm)", 1),
("pressure_msl", "Pressure (mb)", 1), ("pressure_msl", "Pressure (mb)", 1),
("sunshine", None, None), ("sunshine", None, None),
@@ -62,13 +60,13 @@ class WeatherBrightSky(WeatherProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
prediction_hours (int, optional): Number of hours in the future for the forecast. hours (int, optional): Number of hours in the future for the forecast.
prediction_historic_hours (int, optional): Number of past hours for retaining data. historic_hours (int, optional): Number of past hours for retaining data.
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90. latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180. longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime. start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -96,10 +94,11 @@ class WeatherBrightSky(WeatherProvider):
ValueError: If the API response does not include expected `weather` data. ValueError: If the API response does not include expected `weather` data.
""" """
source = "https://api.brightsky.dev" source = "https://api.brightsky.dev"
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD") date = to_datetime(self.start_datetime, as_string=True)
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") last_date = to_datetime(self.end_datetime, as_string=True)
response = requests.get( response = requests.get(
f"{source}/weather?lat={self.config.latitude}&lon={self.config.longitude}&date={date}&last_date={last_date}&tz={self.config.timezone}" f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}",
timeout=10,
) )
response.raise_for_status() # Raise an error for bad responses response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}") logger.debug(f"Response from {source}: {response}")
@@ -109,7 +108,7 @@ class WeatherBrightSky(WeatherProvider):
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return brightsky_data return brightsky_data
def _description_to_series(self, description: str) -> pd.Series: def _description_to_series(self, description: str) -> pd.Series:
@@ -133,7 +132,8 @@ class WeatherBrightSky(WeatherProvider):
error_msg = f"No WeatherDataRecord key for '{description}'" error_msg = f"No WeatherDataRecord key for '{description}'"
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
return self.key_to_series(key) series = self.key_to_series(key)
return series
def _description_from_series(self, description: str, data: pd.Series) -> None: def _description_from_series(self, description: str, data: pd.Series) -> None:
"""Update a weather data with a pandas Series based on its description. """Update a weather data with a pandas Series based on its description.
@@ -170,7 +170,7 @@ class WeatherBrightSky(WeatherProvider):
brightsky_data = self._request_forecast(force_update=force_update) # type: ignore brightsky_data = self._request_forecast(force_update=force_update) # type: ignore
# Get key mapping from description # Get key mapping from description
brightsky_key_mapping: Dict[str, Tuple[Optional[str], Optional[float]]] = {} brightsky_key_mapping: Dict[str, Tuple[Optional[str], Optional[Union[str, float]]]] = {}
for brightsky_key, description, corr_factor in WheaterDataBrightSkyMapping: for brightsky_key, description, corr_factor in WheaterDataBrightSkyMapping:
if description is None: if description is None:
brightsky_key_mapping[brightsky_key] = (None, None) brightsky_key_mapping[brightsky_key] = (None, None)
@@ -192,7 +192,10 @@ class WeatherBrightSky(WeatherProvider):
value = brightsky_record[brightsky_key] value = brightsky_record[brightsky_key]
corr_factor = item[1] corr_factor = item[1]
if value and corr_factor: if value and corr_factor:
value = value * corr_factor if corr_factor == "to datetime in timezone":
value = to_datetime(value, in_timezone=self.config.general.timezone)
else:
value = value * corr_factor
setattr(weather_record, key, value) setattr(weather_record, key, value)
self.insert_by_datetime(weather_record) self.insert_by_datetime(weather_record)
@@ -200,7 +203,7 @@ class WeatherBrightSky(WeatherProvider):
description = "Total Clouds (% Sky Obscured)" description = "Total Clouds (% Sky Obscured)"
cloud_cover = self._description_to_series(description) cloud_cover = self._description_to_series(description)
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover( ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
self.config.latitude, self.config.longitude, cloud_cover self.config.general.latitude, self.config.general.longitude, cloud_cover
) )
description = "Global Horizontal Irradiance (W/m2)" description = "Global Horizontal Irradiance (W/m2)"
@@ -216,14 +219,40 @@ class WeatherBrightSky(WeatherProvider):
self._description_from_series(description, dhi) self._description_from_series(description, dhi)
# Add Preciptable Water (PWAT) with a PVLib method. # Add Preciptable Water (PWAT) with a PVLib method.
description = "Temperature (°C)" key = WeatherDataRecord.key_from_description("Temperature (°C)")
temperature = self._description_to_series(description) assert key # noqa: S101
temperature = self.key_to_array(
description = "Relative Humidity (%)" key=key,
humidity = self._description_to_series(description) start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=to_duration("1 hour"),
)
if any(x is None or isinstance(x, float) and np.isnan(x) for x in temperature):
# We can not calculate PWAT
debug_msg = f"Innvalid temperature '{temperature}'"
logger.debug(debug_msg)
return
key = WeatherDataRecord.key_from_description("Relative Humidity (%)")
assert key # noqa: S101
humidity = self.key_to_array(
key=key,
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=to_duration("1 hour"),
)
if any(x is None or isinstance(x, float) and np.isnan(x) for x in humidity):
# We can not calculate PWAT
debug_msg = f"Innvalid humidity '{humidity}'"
logger.debug(debug_msg)
return
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
pwat = pd.Series( pwat = pd.Series(
data=pvlib.atmosphere.gueymard94_pw(temperature, humidity), index=temperature.index data=data,
index=pd.DatetimeIndex(
pd.date_range(
start=self.start_datetime, end=self.end_datetime, freq="1h", inclusive="left"
)
),
) )
description = "Preciptable Water (cm)" description = "Preciptable Water (cm)"
self._description_from_series(description, pwat) self._description_from_series(description, pwat)

View File

@@ -18,15 +18,12 @@ from typing import Dict, List, Optional, Tuple
import pandas as pd import pandas as pd
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from loguru import logger
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
from akkudoktoreos.utils.cacheutil import cache_in_file
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_timezone from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_timezone
logger = get_logger(__name__)
WheaterDataClearOutsideMapping: List[Tuple[str, Optional[str], Optional[float]]] = [ WheaterDataClearOutsideMapping: List[Tuple[str, Optional[str], Optional[float]]] = [
# clearoutside_key, description, corr_factor # clearoutside_key, description, corr_factor
("DateTime", "DateTime", None), ("DateTime", "DateTime", None),
@@ -68,15 +65,15 @@ class WeatherClearOutside(WeatherProvider):
WeatherClearOutside is a thread-safe singleton, ensuring only one instance of this class is created. WeatherClearOutside is a thread-safe singleton, ensuring only one instance of this class is created.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range, end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
@classmethod @classmethod
@@ -88,16 +85,16 @@ class WeatherClearOutside(WeatherProvider):
"""Requests weather forecast from ClearOutside. """Requests weather forecast from ClearOutside.
Returns: Returns:
response: Weather forecast request reponse from ClearOutside. response: Weather forecast request response from ClearOutside.
""" """
source = "https://clearoutside.com/forecast" source = "https://clearoutside.com/forecast"
latitude = round(self.config.latitude, 2) latitude = round(self.config.general.latitude, 2)
longitude = round(self.config.longitude, 2) longitude = round(self.config.general.longitude, 2)
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true") response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true", timeout=10)
response.raise_for_status() # Raise an error for bad responses response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}") logger.debug(f"Response from {source}: {response}")
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return response return response
def _update_data(self, force_update: Optional[bool] = None) -> None: def _update_data(self, force_update: Optional[bool] = None) -> None:
@@ -307,7 +304,7 @@ class WeatherClearOutside(WeatherProvider):
data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"] data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"]
) )
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover( ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
self.config.latitude, self.config.longitude, cloud_cover self.config.general.latitude, self.config.general.longitude, cloud_cover
) )
# Add GHI, DNI, DHI to clearout data # Add GHI, DNI, DHI to clearout data

View File

@@ -9,31 +9,33 @@ format, enabling consistent access to forecasted and historical weather attribut
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
from akkudoktoreos.prediction.weatherabc import WeatherProvider from akkudoktoreos.prediction.weatherabc import WeatherProvider
logger = get_logger(__name__)
class WeatherImportCommonSettings(SettingsBaseModel): class WeatherImportCommonSettings(SettingsBaseModel):
"""Common settings for weather data import from file or JSON string.""" """Common settings for weather data import from file or JSON string."""
weatherimport_file_path: Optional[Union[str, Path]] = Field( import_file_path: Optional[Union[str, Path]] = Field(
default=None, description="Path to the file to import weather data from." default=None,
description="Path to the file to import weather data from.",
examples=[None, "/path/to/weather_data.json"],
) )
weatherimport_json: Optional[str] = Field( import_json: Optional[str] = Field(
default=None, description="JSON string, dictionary of weather forecast value lists." default=None,
description="JSON string, dictionary of weather forecast value lists.",
examples=['{"weather_temp_air": [18.3, 17.8, 16.9]}'],
) )
# Validators # Validators
@field_validator("weatherimport_file_path", mode="after") @field_validator("import_file_path", mode="after")
@classmethod @classmethod
def validate_weatherimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]: def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
if value is None: if value is None:
return None return None
if isinstance(value, str): if isinstance(value, str):
@@ -59,7 +61,14 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
return "WeatherImport" return "WeatherImport"
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.weatherimport_file_path is not None: if self.config.weather.provider_settings is None:
self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather") logger.debug(f"{self.provider_id()} data update without provider settings.")
if self.config.weatherimport_json is not None: return
self.import_from_json(self.config.weatherimport_json, key_prefix="weather") if self.config.weather.provider_settings.import_file_path:
self.import_from_file(
self.config.weather.provider_settings.import_file_path, key_prefix="weather"
)
if self.config.weather.provider_settings.import_json:
self.import_from_json(
self.config.weather.provider_settings.import_json, key_prefix="weather"
)

View File

@@ -0,0 +1,297 @@
"""Admin UI components for EOS Dashboard.
This module provides functions to generate administrative UI components
for the EOS dashboard.
"""
import json
from pathlib import Path
from typing import Any, Optional, Union
import requests
from fasthtml.common import Select
from loguru import logger
from monsterui.foundations import stringify
from monsterui.franken import ( # Select, TODO: Select from FrankenUI does not work - using Select from FastHTML instead
H3,
Button,
ButtonT,
Card,
Details,
Div,
DivHStacked,
DividerLine,
Grid,
Input,
Options,
P,
Summary,
UkIcon,
)
from platformdirs import user_config_dir
from akkudoktoreos.server.dash.components import Error, Success
from akkudoktoreos.server.dash.configuration import get_nested_value
from akkudoktoreos.utils.datetimeutil import to_datetime
# Directory to export files to, or to import files from
export_import_directory = Path(user_config_dir("net.akkudoktor.eosdash", "akkudoktor"))
def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
"""Creates a styled button for administrative actions.
Args:
*c (Any): Positional arguments representing the button's content.
cls (Optional[Union[str, tuple]]): Additional CSS classes for styling. Defaults to None.
**kwargs (Any): Additional keyword arguments passed to the `Button`.
Returns:
Button: A styled `Button` component for admin actions.
"""
new_cls = f"{ButtonT.primary}"
if cls:
new_cls += f" {stringify(cls)}"
kwargs["cls"] = new_cls
return Button(*c, submit=False, **kwargs)
def AdminConfig(
eos_host: str, eos_port: Union[str, int], data: Optional[dict], config: Optional[dict[str, Any]]
) -> tuple[str, Union[Card, list[Card]]]:
"""Creates a configuration management card with save-to-file functionality.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
data (Optional[dict]): Incoming data containing action and category for processing.
Returns:
tuple[str, Union[Card, list[Card]]]: A tuple containing the configuration category label and the `Card` UI component.
"""
server = f"http://{eos_host}:{eos_port}"
eos_hostname = "EOS server"
eosdash_hostname = "EOSdash server"
category = "configuration"
# save config file
status = (None,)
config_file_path = "<unknown>"
try:
if config:
config_file_path = get_nested_value(config, ["general", "config_file_path"])
except Exception as e:
logger.debug(f"general.config_file_path: {e}")
# export config file
export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss")
export_to_file_status = (None,)
# import config file
import_from_file_status = (None,)
if data and data.get("category", None) == category:
# This data is for us
if data["action"] == "save_to_file":
# Safe current configuration to file
try:
result = requests.put(f"{server}/v1/config/file", timeout=10)
result.raise_for_status()
config_file_path = result.json()["general"]["config_file_path"]
status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'")
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
status = Error(
f"Can not save actual config to file on '{eos_hostname}': {e}, {detail}"
)
except Exception as e:
status = Error(f"Can not save actual config to file on '{eos_hostname}': {e}")
elif data["action"] == "export_to_file":
# Export current configuration to file
export_to_file_tag = data.get("export_to_file_tag", export_to_file_next_tag)
export_to_file_path = export_import_directory.joinpath(
f"eos_config_{export_to_file_tag}.json"
)
try:
if not config:
raise ValueError(f"No config from '{eos_hostname}'")
export_to_file_path.parent.mkdir(parents=True, exist_ok=True)
with export_to_file_path.open("w", encoding="utf-8", newline="\n") as fd:
json.dump(config, fd, indent=4, sort_keys=True)
export_to_file_status = Success(
f"Exported to '{export_to_file_path}' on '{eosdash_hostname}'"
)
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
export_to_file_status = Error(
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}, {detail}"
)
except Exception as e:
export_to_file_status = Error(
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}"
)
elif data["action"] == "import_from_file":
import_file_name = data.get("import_file_name", None)
import_from_file_pathes = list(
export_import_directory.glob("*.json")
) # expand generator object
import_file_path = None
for f in import_from_file_pathes:
if f.name == import_file_name:
import_file_path = f
if import_file_path:
try:
with import_file_path.open("r", encoding="utf-8", newline=None) as fd:
import_config = json.load(fd)
result = requests.put(f"{server}/v1/config", json=import_config, timeout=10)
result.raise_for_status()
import_from_file_status = Success(
f"Config imported from '{import_file_path}' on '{eosdash_hostname}'"
)
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
import_from_file_status = Error(
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}, {detail}"
)
except Exception as e:
import_from_file_status = Error(
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}"
)
else:
import_from_file_status = Error(
f"Can not import config from '{import_file_name}', not found in '{export_import_directory}' on '{eosdash_hostname}'"
)
# Update for display, in case we added a new file before
import_from_file_names = [f.name for f in list(export_import_directory.glob("*.json"))]
return (
category,
[
Card(
Details(
Summary(
Grid(
DivHStacked(
UkIcon(icon="play"),
AdminButton(
"Save to file",
hx_post="/eosdash/admin",
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='{"category": "configuration", "action": "save_to_file"}',
),
P(f"'{config_file_path}' on '{eos_hostname}'"),
),
status,
),
cls="list-none",
),
P(f"Safe actual configuration to '{config_file_path}' on '{eos_hostname}'."),
),
),
Card(
Details(
Summary(
Grid(
DivHStacked(
UkIcon(icon="play"),
AdminButton(
"Export to file",
hx_post="/eosdash/admin",
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='js:{"category": "configuration", "action": "export_to_file", "export_to_file_tag": document.querySelector("[name=\'chosen_export_file_tag\']").value }',
),
P("'eos_config_"),
Input(
id="export_file_tag",
name="chosen_export_file_tag",
value=export_to_file_next_tag,
),
P(".json'"),
),
export_to_file_status,
),
cls="list-none",
),
P(
f"Export actual configuration to 'eos_config_{export_to_file_next_tag}.json' on '{eosdash_hostname}'."
),
),
),
Card(
Details(
Summary(
Grid(
DivHStacked(
UkIcon(icon="play"),
AdminButton(
"Import from file",
hx_post="/eosdash/admin",
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='js:{ "category": "configuration", "action": "import_from_file", "import_file_name": document.querySelector("[name=\'selected_import_file_name\']").value }',
),
Select(
*Options(*import_from_file_names),
id="import_file_name",
name="selected_import_file_name", # Name of hidden input field with selected value
placeholder="Select file",
),
),
import_from_file_status,
),
cls="list-none",
),
P(f"Import configuration from config file on '{eosdash_hostname}'."),
),
),
],
)
def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) -> Div:
"""Generates the administrative dashboard layout.
This includes configuration management and other administrative tools.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
data (Optional[dict], optional): Incoming data to trigger admin actions. Defaults to None.
Returns:
Div: A `Div` component containing the assembled admin interface.
"""
# Get current configuration from server
server = f"http://{eos_host}:{eos_port}"
try:
result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status()
config = result.json()
except requests.exceptions.HTTPError as e:
config = {}
detail = result.json()["detail"]
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
logger.warning(warning_msg)
return Error(warning_msg)
except Exception as e:
warning_msg = f"Can not retrieve configuration from {server}: {e}"
logger.warning(warning_msg)
return Error(warning_msg)
rows = []
last_category = ""
for category, admin in [
AdminConfig(eos_host, eos_port, data, config),
]:
if category != last_category:
rows.append(H3(category))
rows.append(DividerLine())
last_category = category
if isinstance(admin, list):
for card in admin:
rows.append(card)
else:
rows.append(admin)
return Div(*rows, cls="space-y-4")

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,38 @@
# Module taken from https://github.com/koaning/fh-altair
# MIT license
from typing import Optional
from bokeh.embed import components
from bokeh.models import Plot
from monsterui.franken import H4, Card, NotStr, Script
BokehJS = [
Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.7.0.min.js", crossorigin="anonymous"),
Script(
src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.7.0.min.js",
crossorigin="anonymous",
),
Script(
src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.7.0.min.js", crossorigin="anonymous"
),
Script(
src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.7.0.min.js", crossorigin="anonymous"
),
Script(
src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.7.0.min.js",
crossorigin="anonymous",
),
]
def Bokeh(plot: Plot, header: Optional[str] = None) -> Card:
"""Converts an Bokeh plot to a FastHTML FT component."""
script, div = components(plot)
if header:
header = H4(header, cls="mt-2")
return Card(
NotStr(div),
NotStr(script),
header=header,
)

View File

@@ -0,0 +1,317 @@
from typing import Any, Optional, Union
from fasthtml.common import H1, Div, Li
from monsterui.daisy import (
Alert,
AlertT,
)
from monsterui.foundations import stringify
from monsterui.franken import (
H3,
Button,
ButtonT,
Card,
Container,
ContainerT,
Details,
DivLAligned,
DivRAligned,
Form,
Grid,
Input,
P,
Summary,
TabContainer,
UkIcon,
)
scrollbar_viewport_styles = (
"scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;"
)
scrollbar_cls = "flex touch-none select-none transition-colors p-[1px]"
def ScrollArea(
*c: Any, cls: Optional[Union[str, tuple]] = None, orientation: str = "vertical", **kwargs: Any
) -> Div:
"""Creates a styled scroll area.
Args:
orientation (str): The orientation of the scroll area. Defaults to vertical.
"""
new_cls = "relative overflow-hidden"
if cls:
new_cls += f" {stringify(cls)}"
kwargs["cls"] = new_cls
content = Div(
Div(*c, style="min-width:100%;display:table;"),
style=f"overflow: {'hidden scroll' if orientation == 'vertical' else 'scroll'}; {scrollbar_viewport_styles}",
cls="w-full h-full rounded-[inherit]",
data_ref="viewport",
)
scrollbar = Div(
Div(cls="bg-border rounded-full hidden relative flex-1", data_ref="thumb"),
cls=f"{scrollbar_cls} flex-col h-2.5 w-full border-t border-t-transparent"
if orientation == "horizontal"
else f"{scrollbar_cls} w-2.5 h-full border-l border-l-transparent",
data_ref="scrollbar",
style=f"position: absolute;{'right:0; top:0;' if orientation == 'vertical' else 'bottom:0; left:0;'}",
)
return Div(
content,
scrollbar,
role="region",
tabindex="0",
data_orientation=orientation,
data_ref_scrollarea=True,
aria_label="Scrollable content",
**kwargs,
)
def Success(*c: Any) -> Alert:
return Alert(
DivLAligned(
UkIcon("check"),
P(*c),
),
cls=AlertT.success,
)
def Error(*c: Any) -> Alert:
return Alert(
DivLAligned(
UkIcon("triangle-alert"),
P(*c),
),
cls=AlertT.error,
)
def ConfigCard(
config_name: str,
config_type: str,
read_only: str,
value: str,
default: str,
description: str,
deprecated: Optional[Union[str, bool]],
update_error: Optional[str],
update_value: Optional[str],
update_open: Optional[bool],
) -> Card:
"""Creates a styled configuration card for displaying configuration details.
This function generates a configuration card that is displayed in the UI with
various sections such as configuration name, type, description, default value,
current value, and error details. It supports both read-only and editable modes.
Args:
config_name (str): The name of the configuration.
config_type (str): The type of the configuration.
read_only (str): Indicates if the configuration is read-only ("rw" for read-write,
any other value indicates read-only).
value (str): The current value of the configuration.
default (str): The default value of the configuration.
description (str): A description of the configuration.
deprecated (Optional[Union[str, bool]]): The deprecated marker of the configuration.
update_error (Optional[str]): The error message, if any, during the update process.
update_value (Optional[str]): The value to be updated, if different from the current value.
update_open (Optional[bool]): A flag indicating whether the update section of the card
should be initially expanded.
Returns:
Card: A styled Card component containing the configuration details.
"""
config_id = config_name.replace(".", "-")
if not update_value:
update_value = value
if not update_open:
update_open = False
if deprecated:
if isinstance(deprecated, bool):
deprecated = "Deprecated"
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
),
DivRAligned(
P(read_only),
),
),
P(value),
),
cls="list-none",
),
Grid(
P(description),
P(config_type),
)
if not deprecated
else None,
Grid(
P(deprecated),
P("DEPRECATED!"),
)
if deprecated
else None,
# Default
Grid(
DivRAligned(P("default")),
P(default),
)
if read_only == "rw" and not deprecated
else None,
# Set value
Grid(
DivRAligned(P("update")),
Grid(
Form(
Input(value=config_name, type="hidden", id="key"),
Input(value=update_value, type="text", id="value"),
hx_put="/eosdash/configuration",
hx_target="#page-content",
hx_swap="innerHTML",
),
),
)
if read_only == "rw" and not deprecated
else None,
# Last error
Grid(
DivRAligned(P("update error")),
P(update_error),
)
if update_error
else None,
cls="space-y-4 gap-4",
open=update_open,
),
cls="w-full",
)
def DashboardHeader(title: Optional[str]) -> Div:
"""Creates a styled header with a title.
Args:
title (Optional[str]): The title text for the header.
Returns:
Div: A styled `Div` element containing the header.
"""
if title is None:
return Div("", cls="header")
return Div(H1(title, cls="text-2xl font-bold mb-4"), cls="header")
def DashboardFooter(*c: Any, path: str) -> Card:
"""Creates a styled footer with the provided information.
The footer content is reloaded every 5 seconds from path.
Args:
path (str): Path to reload footer content from
Returns:
Card: A styled `Card` element containing the footer.
"""
return Card(
Container(*c, id="footer-content"),
hx_get=f"{path}",
hx_trigger="every 5s",
hx_target="#footer-content",
hx_swap="innerHTML",
)
def DashboardTrigger(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
"""Creates a styled button for the dashboard trigger.
Args:
*c: Positional arguments to pass to the button.
cls (Optional[str]): Additional CSS classes for styling. Defaults to None.
**kwargs: Additional keyword arguments for the button.
Returns:
Button: A styled `Button` component.
"""
new_cls = f"{ButtonT.primary}"
if cls:
new_cls += f" {stringify(cls)}"
kwargs["cls"] = new_cls
return Button(*c, submit=False, **kwargs)
def DashboardTabs(dashboard_items: dict[str, str]) -> Card:
"""Creates a dashboard tab with dynamic dashboard items.
Args:
dashboard_items (dict[str, str]): A dictionary of dashboard items where keys are item names
and values are paths for navigation.
Returns:
Card: A styled `Card` component containing the dashboard tabs.
"""
dash_items = [
Li(
DashboardTrigger(
H3(menu),
hx_get=f"{path}",
hx_target="#page-content",
hx_swap="innerHTML",
),
)
for menu, path in dashboard_items.items()
]
return Card(TabContainer(*dash_items, cls="gap-4"), alt=True)
def DashboardContent(content: Any) -> Card:
"""Creates a content section within a styled card.
Args:
content (Any): The content to display.
Returns:
Card: A styled `Card` element containing the content.
"""
return Card(ScrollArea(Container(content, id="page-content"), cls="h-[75vh] w-full rounded-md"))
def Page(
title: Optional[str],
dashboard_items: dict[str, str],
content: Any,
footer_content: Any,
footer_path: str,
) -> Div:
"""Generates a full-page layout with a header, dashboard items, content, and footer.
Args:
title (Optional[str]): The page title.
dashboard_items (dict[str, str]): A dictionary of dashboard items.
content (Any): The main content for the page.
footer_content (Any): Footer content.
footer_path (Any): Path to reload footer content from.
Returns:
Div: A `Div` element representing the entire page layout.
"""
return Container(
DashboardHeader(title),
DashboardTabs(dashboard_items),
DashboardContent(content),
DashboardFooter(footer_content, path=footer_path),
cls=("bg-background text-foreground w-screen p-4 space-y-4", ContainerT.xl),
)

View File

@@ -0,0 +1,549 @@
import json
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
import requests
from loguru import logger
from monsterui.franken import (
H3,
H4,
Card,
Details,
Div,
DividerLine,
DivLAligned,
DivRAligned,
Form,
Grid,
Input,
P,
Summary,
UkIcon,
)
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
from akkudoktoreos.server.dash.components import ConfigCard
T = TypeVar("T")
# Latest configuration update results
# Dictionary of config names and associated dictionary with keys "value", "result", "error", "open".
config_update_latest: dict[str, dict[str, Optional[Union[str, bool]]]] = {}
def get_nested_value(
dictionary: Union[Dict[str, Any], List[Any]],
keys: Sequence[Union[str, int]],
default: Optional[T] = None,
) -> Union[Any, T]:
"""Retrieve a nested value from a dictionary or list using a sequence of keys.
Args:
dictionary (Union[Dict[str, Any], List[Any]]): The nested dictionary or list to search.
keys (Sequence[Union[str, int]]): A sequence of keys or indices representing the path to the desired value.
default (Optional[T]): A value to return if the path is not found.
Returns:
Union[Any, T]: The value at the specified nested path, or the default value if not found.
Raises:
TypeError: If the input is not a dictionary or list, or if keys are not a sequence.
KeyError: If a key is not found in a dictionary.
IndexError: If an index is out of range in a list.
"""
if not isinstance(dictionary, (dict, list)):
raise TypeError("The first argument must be a dictionary or list")
if not isinstance(keys, Sequence):
raise TypeError("Keys must be provided as a sequence (e.g., list, tuple)")
if not keys:
return dictionary
try:
# Traverse the structure
current = dictionary
for key in keys:
if isinstance(current, dict):
current = current[str(key)]
elif isinstance(current, list):
current = current[int(key)]
else:
raise KeyError(f"Invalid key or index: {key}")
return current
except (KeyError, IndexError, TypeError):
return default
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
"""Retrieve the default value of a field.
Args:
field_info (Union[FieldInfo, ComputedFieldInfo]): The field metadata from Pydantic.
regular_field (bool): Indicates if the field is a regular field.
Returns:
Any: The default value of the field or "N/A" if not a regular field.
"""
default_value = ""
if regular_field:
if (val := field_info.default) is not PydanticUndefined:
default_value = val
else:
default_value = "N/A"
return default_value
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
"""Resolve nested types within a field and return their structure.
Args:
field_type (Any): The type of the field to resolve.
parent_types (List[str]): A list of parent type names.
Returns:
List[tuple[Any, List[str]]]: A list of tuples containing resolved types and their parent hierarchy.
"""
resolved_types: list[tuple[Any, list[str]]] = []
origin = getattr(field_type, "__origin__", field_type)
if origin is Union:
for arg in getattr(field_type, "__args__", []):
if arg is not type(None):
resolved_types.extend(resolve_nested_types(arg, parent_types))
else:
resolved_types.append((field_type, parent_types))
return resolved_types
def configuration(
model: type[PydanticBaseModel], values: dict, values_prefix: list[str] = []
) -> list[dict]:
"""Generate configuration details based on provided values and model metadata.
Args:
model (type[PydanticBaseModel]): The Pydantic model to extract configuration from.
values (dict): A dictionary containing the current configuration values.
values_prefix (list[str]): A list of parent type names that prefixes the model values in the values.
Returns:
list[dict]: A sorted list of configuration details, each represented as a dictionary.
"""
configs = []
inner_types: set[type[PydanticBaseModel]] = set()
for field_name, field_info in list(model.model_fields.items()) + list(
model.model_computed_fields.items()
):
def extract_nested_models(
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
) -> None:
"""Extract nested models from the given subfield information.
Args:
subfield_info (Union[ComputedFieldInfo, FieldInfo]): Field metadata from Pydantic.
parent_types (list[str]): A list of parent type names for hierarchical representation.
"""
nonlocal values, values_prefix
regular_field = isinstance(subfield_info, FieldInfo)
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
if subtype in inner_types:
return
nested_types = resolve_nested_types(subtype, [])
found_basic = False
for nested_type, nested_parent_types in nested_types:
if not isinstance(nested_type, type) or not issubclass(
nested_type, PydanticBaseModel
):
if found_basic:
continue
config: dict[str, Optional[Any]] = {}
config["name"] = ".".join(values_prefix + parent_types)
config["value"] = json.dumps(
get_nested_value(values, values_prefix + parent_types, "<unknown>")
)
config["default"] = json.dumps(get_default_value(subfield_info, regular_field))
config["description"] = (
subfield_info.description if subfield_info.description else ""
)
config["deprecated"] = (
subfield_info.deprecated if subfield_info.deprecated else None
)
if isinstance(subfield_info, ComputedFieldInfo):
config["read-only"] = "ro"
type_description = str(subfield_info.return_type)
else:
config["read-only"] = "rw"
type_description = str(subfield_info.annotation)
config["type"] = (
type_description.replace("typing.", "")
.replace("pathlib.", "")
.replace("NoneType", "None")
.replace("<class 'float'>", "float")
)
configs.append(config)
found_basic = True
else:
new_parent_types = parent_types + nested_parent_types
inner_types.add(nested_type)
for nested_field_name, nested_field_info in list(
nested_type.model_fields.items()
) + list(nested_type.model_computed_fields.items()):
extract_nested_models(
nested_field_info,
new_parent_types + [nested_field_name],
)
extract_nested_models(field_info, [field_name])
return sorted(configs, key=lambda x: x["name"])
def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]:
"""Fetch and process configuration data from the specified EOS server.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
Returns:
List[dict]: A list of processed configuration entries.
"""
server = f"http://{eos_host}:{eos_port}"
# Get current configuration from server
try:
result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status()
config = result.json()
except requests.exceptions.HTTPError as e:
config = {}
detail = result.json()["detail"]
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
logger.warning(warning_msg)
return configuration(ConfigEOS, config)
def ConfigPlanesCard(
config_name: str,
config_type: str,
read_only: str,
value: str,
default: str,
description: str,
max_planes: int,
update_error: Optional[str],
update_value: Optional[str],
update_open: Optional[bool],
) -> Card:
"""Creates a styled configuration card for PV planes.
This function generates a configuration card that is displayed in the UI with
various sections such as configuration name, type, description, default value,
current value, and error details. It supports both read-only and editable modes.
Args:
config_name (str): The name of the PV planes configuration.
config_type (str): The type of the PV planes configuration.
read_only (str): Indicates if the PV planes configuration is read-only ("rw" for read-write,
any other value indicates read-only).
value (str): The current value of the PV planes configuration.
default (str): The default value of the PV planes configuration.
description (str): A description of the PV planes configuration.
max_planes (int): Maximum number of planes that can be set
update_error (Optional[str]): The error message, if any, during the update process.
update_value (Optional[str]): The value to be updated, if different from the current value.
update_open (Optional[bool]): A flag indicating whether the update section of the card
should be initially expanded.
Returns:
Card: A styled Card component containing the PV planes configuration details.
"""
config_id = config_name.replace(".", "-")
# Remember overall planes update status
planes_update_error = update_error
planes_update_value = update_value
if not planes_update_value:
planes_update_value = value
planes_update_open = update_open
if not planes_update_open:
planes_update_open = False
# Create EOS planes configuration
eos_planes = json.loads(value)
eos_planes_config = {
"pvforecast": {
"planes": eos_planes,
},
}
# Create cards for all planes
rows = []
for i in range(0, max_planes):
plane_config = configuration(
PVForecastPlaneSetting(),
eos_planes_config,
values_prefix=["pvforecast", "planes", str(i)],
)
plane_rows = []
plane_update_open = False
if eos_planes and len(eos_planes) > i:
plane_value = json.dumps(eos_planes[i])
else:
plane_value = json.dumps(None)
for config in plane_config:
update_error = config_update_latest.get(config["name"], {}).get("error") # type: ignore
update_value = config_update_latest.get(config["name"], {}).get("value") # type: ignore
update_open = config_update_latest.get(config["name"], {}).get("open") # type: ignore
if update_open:
planes_update_open = True
plane_update_open = True
# Make mypy happy - should never trigger
if (
not isinstance(update_error, (str, type(None)))
or not isinstance(update_value, (str, type(None)))
or not isinstance(update_open, (bool, type(None)))
):
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
plane_rows.append(
ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
update_error,
update_value,
update_open,
)
)
rows.append(
Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
H4(f"pvforecast.planes.{i}"),
),
DivRAligned(
P(read_only),
),
),
P(plane_value),
),
cls="list-none",
),
*plane_rows,
cls="space-y-4 gap-4",
open=plane_update_open,
),
cls="w-full",
)
)
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
),
DivRAligned(
P(read_only),
),
),
P(value),
),
cls="list-none",
),
Grid(
P(description),
P(config_type),
),
# Default
Grid(
DivRAligned(P("default")),
P(default),
)
if read_only == "rw"
else None,
# Set value
Grid(
DivRAligned(P("update")),
Grid(
Form(
Input(value=config_name, type="hidden", id="key"),
Input(value=planes_update_value, type="text", id="value"),
hx_put="/eosdash/configuration",
hx_target="#page-content",
hx_swap="innerHTML",
),
),
)
if read_only == "rw"
else None,
# Last error
Grid(
DivRAligned(P("update error")),
P(planes_update_error),
)
if planes_update_error
else None,
# Now come the single element configs
*rows,
cls="space-y-4 gap-4",
open=planes_update_open,
),
cls="w-full",
)
def Configuration(
eos_host: str, eos_port: Union[str, int], configuration: Optional[list[dict]] = None
) -> Div:
"""Create a visual representation of the configuration.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
configuration (Optional[list[dict]]): Optional configuration. If not provided it will be
retrievd from EOS.
Returns:
rows: Rows of configuration details.
"""
if not configuration:
configuration = get_configuration(eos_host, eos_port)
rows = []
last_category = ""
# find some special configuration values
max_planes = 0
for config in configuration:
if config["name"] == "pvforecast.max_planes":
try:
max_planes = int(config["value"])
except:
max_planes = 0
# build visual representation
for config in configuration:
category = config["name"].split(".")[0]
if category != last_category:
rows.append(H3(category))
rows.append(DividerLine())
last_category = category
update_error = config_update_latest.get(config["name"], {}).get("error")
update_value = config_update_latest.get(config["name"], {}).get("value")
update_open = config_update_latest.get(config["name"], {}).get("open")
# Make mypy happy - should never trigger
if (
not isinstance(update_error, (str, type(None)))
or not isinstance(update_value, (str, type(None)))
or not isinstance(update_open, (bool, type(None)))
):
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
if (
config["type"]
== "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]"
and not config["deprecated"]
):
# Special configuration for PV planes
rows.append(
ConfigPlanesCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
max_planes,
update_error,
update_value,
update_open,
)
)
else:
rows.append(
ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
update_error,
update_value,
update_open,
)
)
return Div(*rows, cls="space-y-4")
def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: str) -> P:
"""Update configuration key and create a visual representation of the configuration.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
key (str): configuration key in dot notation
value (str): configuration value as json string
Returns:
rows: Rows of configuration details.
"""
server = f"http://{eos_host}:{eos_port}"
path = key.replace(".", "/")
try:
data = json.loads(value)
except:
if value in ("None", "none", "Null", "null"):
data = None
else:
data = value
error = None
config = None
try:
response = requests.put(f"{server}/v1/config/{path}", json=data, timeout=10)
response.raise_for_status()
config = response.json()
except requests.exceptions.HTTPError as err:
try:
# Try to get 'detail' from the JSON response
detail = response.json().get(
"detail", f"No error details for data '{data}' '{response.text}'"
)
except ValueError:
# Response is not JSON
detail = f"No error details for data '{data}' '{response.text}'"
error = f"Can not set {key} on {server}: {err}, {detail}"
# Mark all updates as closed
for k in config_update_latest:
config_update_latest[k]["open"] = False
# Remember this update as latest one
config_update_latest[key] = {
"error": error,
"result": config,
"value": value,
"open": True,
}
if error or config is None:
# Reread configuration to be shure we display actual data
return Configuration(eos_host, eos_port)
# Use configuration already provided
return Configuration(eos_host, eos_port, configuration(ConfigEOS, config))

View File

@@ -0,0 +1,86 @@
{
"elecprice": {
"charges_kwh": 0.21,
"provider": "ElecPriceAkkudoktor"
},
"general": {
"latitude": 52.5,
"longitude": 13.4
},
"prediction": {
"historic_hours": 48,
"hours": 48
},
"load": {
"provider": "LoadAkkudoktor",
"provider_settings": {
"loadakkudoktor_year_energy": 20000
}
},
"optimization": {
"hours": 48
},
"pvforecast": {
"planes": [
{
"peakpower": 5.0,
"surface_azimuth": 170,
"surface_tilt": 7,
"userhorizon": [
20,
27,
22,
20
],
"inverter_paco": 10000
},
{
"peakpower": 4.8,
"surface_azimuth": 90,
"surface_tilt": 7,
"userhorizon": [
30,
30,
30,
50
],
"inverter_paco": 10000
},
{
"peakpower": 1.4,
"surface_azimuth": 140,
"surface_tilt": 60,
"userhorizon": [
60,
30,
0,
30
],
"inverter_paco": 2000
},
{
"peakpower": 1.6,
"surface_azimuth": 185,
"surface_tilt": 45,
"userhorizon": [
45,
25,
30,
60
],
"inverter_paco": 1400
}
],
"provider": "PVForecastAkkudoktor"
},
"server": {
"startup_eosdash": true,
"host": "127.0.0.1",
"port": 8503,
"eosdash_host": "127.0.0.1",
"eosdash_port": 8504
},
"weather": {
"provider": "BrightSky"
}
}

View File

@@ -0,0 +1,280 @@
import json
from pathlib import Path
from typing import Union
import pandas as pd
import requests
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
from bokeh.plotting import figure
from monsterui.franken import FT, Grid, P
from akkudoktoreos.core.pydantic import PydanticDateTimeDataFrame
from akkudoktoreos.server.dash.bokeh import Bokeh
DIR_DEMODATA = Path(__file__).absolute().parent.joinpath("data")
FILE_DEMOCONFIG = DIR_DEMODATA.joinpath("democonfig.json")
if not FILE_DEMOCONFIG.exists():
raise ValueError(f"File does not exist: {FILE_DEMOCONFIG}")
# bar width for 1 hour bars (time given in millseconds)
BAR_WIDTH_1HOUR = 1000 * 60 * 60
def DemoPVForecast(predictions: pd.DataFrame, config: dict) -> FT:
source = ColumnDataSource(predictions)
provider = config["pvforecast"]["provider"]
plot = figure(
x_axis_type="datetime",
title=f"PV Power Prediction ({provider})",
x_axis_label="Datetime",
y_axis_label="Power [W]",
sizing_mode="stretch_width",
height=400,
)
plot.vbar(
x="date_time",
top="pvforecast_ac_power",
source=source,
width=BAR_WIDTH_1HOUR * 0.8,
legend_label="AC Power",
color="lightblue",
)
return Bokeh(plot)
def DemoElectricityPriceForecast(predictions: pd.DataFrame, config: dict) -> FT:
source = ColumnDataSource(predictions)
provider = config["elecprice"]["provider"]
plot = figure(
x_axis_type="datetime",
y_range=Range1d(
predictions["elecprice_marketprice_kwh"].min() - 0.1,
predictions["elecprice_marketprice_kwh"].max() + 0.1,
),
title=f"Electricity Price Prediction ({provider})",
x_axis_label="Datetime",
y_axis_label="Price [€/kWh]",
sizing_mode="stretch_width",
height=400,
)
plot.vbar(
x="date_time",
top="elecprice_marketprice_kwh",
source=source,
width=BAR_WIDTH_1HOUR * 0.8,
legend_label="Market Price",
color="lightblue",
)
return Bokeh(plot)
def DemoWeatherTempAirHumidity(predictions: pd.DataFrame, config: dict) -> FT:
source = ColumnDataSource(predictions)
provider = config["weather"]["provider"]
plot = figure(
x_axis_type="datetime",
title=f"Air Temperature and Humidity Prediction ({provider})",
x_axis_label="Datetime",
y_axis_label="Temperature [°C]",
sizing_mode="stretch_width",
height=400,
)
# Add secondary y-axis for humidity
plot.extra_y_ranges["humidity"] = Range1d(start=-5, end=105)
y2_axis = LinearAxis(y_range_name="humidity", axis_label="Relative Humidity [%]")
y2_axis.axis_label_text_color = "green"
plot.add_layout(y2_axis, "left")
plot.line(
"date_time", "weather_temp_air", source=source, legend_label="Air Temperature", color="blue"
)
plot.line(
"date_time",
"weather_relative_humidity",
source=source,
legend_label="Relative Humidity [%]",
color="green",
y_range_name="humidity",
)
return Bokeh(plot)
def DemoWeatherIrradiance(predictions: pd.DataFrame, config: dict) -> FT:
source = ColumnDataSource(predictions)
provider = config["weather"]["provider"]
plot = figure(
x_axis_type="datetime",
title=f"Irradiance Prediction ({provider})",
x_axis_label="Datetime",
y_axis_label="Irradiance [W/m2]",
sizing_mode="stretch_width",
height=400,
)
plot.line(
"date_time",
"weather_ghi",
source=source,
legend_label="Global Horizontal Irradiance",
color="red",
)
plot.line(
"date_time",
"weather_dni",
source=source,
legend_label="Direct Normal Irradiance",
color="green",
)
plot.line(
"date_time",
"weather_dhi",
source=source,
legend_label="Diffuse Horizontal Irradiance",
color="blue",
)
return Bokeh(plot)
def DemoLoad(predictions: pd.DataFrame, config: dict) -> FT:
source = ColumnDataSource(predictions)
provider = config["load"]["provider"]
if provider == "LoadAkkudoktor":
year_energy = config["load"]["provider_settings"]["loadakkudoktor_year_energy"]
provider = f"{provider}, {year_energy} kWh"
plot = figure(
x_axis_type="datetime",
title=f"Load Prediction ({provider})",
x_axis_label="Datetime",
y_axis_label="Load [W]",
sizing_mode="stretch_width",
height=400,
)
# Add secondary y-axis for stddev
stddev_min = predictions["load_std"].min()
stddev_max = predictions["load_std"].max()
plot.extra_y_ranges["stddev"] = Range1d(start=stddev_min - 5, end=stddev_max + 5)
y2_axis = LinearAxis(y_range_name="stddev", axis_label="Load Standard Deviation [W]")
y2_axis.axis_label_text_color = "green"
plot.add_layout(y2_axis, "left")
plot.line(
"date_time",
"load_mean",
source=source,
legend_label="Load mean value",
color="red",
)
plot.line(
"date_time",
"load_mean_adjusted",
source=source,
legend_label="Load adjusted by measurement",
color="blue",
)
plot.line(
"date_time",
"load_std",
source=source,
legend_label="Load standard deviation",
color="green",
y_range_name="stddev",
)
return Bokeh(plot)
def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
server = f"http://{eos_host}:{eos_port}"
# Get current configuration from server
try:
result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status()
except requests.exceptions.HTTPError as err:
detail = result.json()["detail"]
return P(
f"Can not retrieve configuration from {server}: {err}, {detail}",
cls="text-center",
)
config = result.json()
# Set demo configuration
with FILE_DEMOCONFIG.open("r", encoding="utf-8") as fd:
democonfig = json.load(fd)
try:
result = requests.put(f"{server}/v1/config", json=democonfig, timeout=10)
result.raise_for_status()
except requests.exceptions.HTTPError as err:
detail = result.json()["detail"]
# Try to reset to original config
requests.put(f"{server}/v1/config", json=config, timeout=10)
return P(
f"Can not set demo configuration on {server}: {err}, {detail}",
cls="text-center",
)
# Update all predictions
try:
result = requests.post(f"{server}/v1/prediction/update", timeout=10)
result.raise_for_status()
except requests.exceptions.HTTPError as err:
detail = result.json()["detail"]
# Try to reset to original config
requests.put(f"{server}/v1/config", json=config, timeout=10)
return P(
f"Can not update predictions on {server}: {err}, {detail}",
cls="text-center",
)
# Get Forecasts
try:
params = {
"keys": [
"pvforecast_ac_power",
"elecprice_marketprice_kwh",
"weather_relative_humidity",
"weather_temp_air",
"weather_ghi",
"weather_dni",
"weather_dhi",
"load_mean",
"load_std",
"load_mean_adjusted",
],
}
result = requests.get(f"{server}/v1/prediction/dataframe", params=params, timeout=10)
result.raise_for_status()
predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe()
except requests.exceptions.HTTPError as err:
detail = result.json()["detail"]
return P(
f"Can not retrieve predictions from {server}: {err}, {detail}",
cls="text-center",
)
except Exception as err:
return P(
f"Can not retrieve predictions from {server}: {err}",
cls="text-center",
)
# Reset to original config
requests.put(f"{server}/v1/config", json=config, timeout=10)
return Grid(
DemoPVForecast(predictions, democonfig),
DemoElectricityPriceForecast(predictions, democonfig),
DemoWeatherTempAirHumidity(predictions, democonfig),
DemoWeatherIrradiance(predictions, democonfig),
DemoLoad(predictions, democonfig),
cols_max=2,
)

Some files were not shown because too many files have changed in this diff Show More