36 Commits

Author SHA1 Message Date
Normann
447f7d05be Update dependabot.yml to also include the feature branch 2025-03-23 22:48:02 +01:00
dependabot[bot]
c72051a08e Bump sphinx from 8.1.3 to 8.2.3 (#476)
Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 8.1.3 to 8.2.3.
- [Release notes](https://github.com/sphinx-doc/sphinx/releases)
- [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst)
- [Commits](https://github.com/sphinx-doc/sphinx/compare/v8.1.3...v8.2.3)

---
updated-dependencies:
- dependency-name: sphinx
  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-03-23 22:14:51 +01:00
Normann
60bd320fbc Python req. to 3.11 for sphinx update (#487)
* Python req. to 3.11 for sphinx update

* Update pyproject.toml
2025-03-23 22:09:02 +01:00
dependabot[bot]
600e332aae Bump platformdirs from 4.3.6 to 4.3.7 (#485)
Bumps [platformdirs](https://github.com/tox-dev/platformdirs) from 4.3.6 to 4.3.7.
- [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.6...4.3.7)

---
updated-dependencies:
- dependency-name: platformdirs
  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-23 21:14:34 +01:00
dependabot[bot]
6a51de04da Bump pvlib from 0.11.2 to 0.12.0 (#486)
Bumps [pvlib](https://github.com/pvlib/pvlib-python) from 0.11.2 to 0.12.0.
- [Release notes](https://github.com/pvlib/pvlib-python/releases)
- [Commits](https://github.com/pvlib/pvlib-python/compare/v0.11.2...v0.12.0)

---
updated-dependencies:
- dependency-name: pvlib
  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-03-23 21:08:51 +01:00
dependabot[bot]
64c8415714 Bump pandas-stubs from 2.2.3.241126 to 2.2.3.250308 (#479)
Bumps [pandas-stubs](https://github.com/pandas-dev/pandas-stubs) from 2.2.3.241126 to 2.2.3.250308.
- [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.241126...v2.2.3.250308)

---
updated-dependencies:
- dependency-name: pandas-stubs
  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-03-23 21:01:48 +01:00
dependabot[bot]
52f8b015b1 Bump numpy from 2.2.3 to 2.2.4 (#483)
Bumps [numpy](https://github.com/numpy/numpy) from 2.2.3 to 2.2.4.
- [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.3...v2.2.4)

---
updated-dependencies:
- dependency-name: numpy
  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-23 21:01:33 +01:00
thiloms
a4adb07ebf 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-03-23 13:52:15 +01:00
dependabot[bot]
b69bbe897f Bump numpydantic from 1.6.7 to 1.6.8 (#480)
Bumps [numpydantic](https://github.com/p2p-ld/numpydantic) from 1.6.7 to 1.6.8.
- [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.7...v1.6.8)

---
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>
2025-03-23 13:51:06 +01:00
dependabot[bot]
0a7420c42b Bump types-requests from 2.32.0.20250301 to 2.32.0.20250306 (#478)
Bumps [types-requests](https://github.com/python/typeshed) from 2.32.0.20250301 to 2.32.0.20250306.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  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-03-23 13:49:47 +01:00
Eric
b22b5ee651 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-03-23 13:27:40 +01:00
dependabot[bot]
9b4ec74823 Bump pytest from 8.3.4 to 8.3.5 (#475)
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
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.4 to 8.3.5.
- [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.4...8.3.5)

---
updated-dependencies:
- dependency-name: pytest
  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-03-05 10:32:48 +01:00
dependabot[bot]
1f30d4e403 Bump python-fasthtml from 0.12.1 to 0.12.4 (#470)
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
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.1 to 0.12.4.
- [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.1...0.12.4)

---
updated-dependencies:
- dependency-name: python-fasthtml
  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-02 11:10:23 +01:00
dependabot[bot]
b563bbbd98 Bump matplotlib from 3.10.0 to 3.10.1 (#471)
Bumps [matplotlib](https://github.com/matplotlib/matplotlib) from 3.10.0 to 3.10.1.
- [Release notes](https://github.com/matplotlib/matplotlib/releases)
- [Commits](https://github.com/matplotlib/matplotlib/compare/v3.10.0...v3.10.1)

---
updated-dependencies:
- dependency-name: matplotlib
  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-02 11:04:30 +01:00
dependabot[bot]
8422a5c9d8 Bump types-requests from 2.32.0.20241016 to 2.32.0.20250301 (#473)
Bumps [types-requests](https://github.com/python/typeshed) from 2.32.0.20241016 to 2.32.0.20250301.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  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-03-02 10:58:46 +01:00
dependabot[bot]
bec5c2cbda Bump fastapi[standard] from 0.115.8 to 0.115.11 (#472)
Bumps [fastapi[standard]](https://github.com/fastapi/fastapi) from 0.115.8 to 0.115.11.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.115.8...0.115.11)

---
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-02 10:57:57 +01:00
Dominique Lasserre
d2136f1447 Dockerfile: Set default for EOS_SERVER__EOSDASH_SESSKEY Closes #447 (#467)
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
* This allows to start the container without any extra settings
   (potentially unsafe).
   It is recommended to set EOS_SERVER__EOSDASH_SESSKEY.
2025-02-23 16:17:54 +01:00
Dominique Lasserre
20621aa626 docker-compose: Expose EOSdash port Closes #447
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
* Fixes direct EOSdash access on Windows localhost:8504 (required for
   redirect).
2025-02-18 07:08:14 +01:00
Dominique Lasserre
76b5ec3638 visualize.py: Support variable remuneration Closes #451 (#459)
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
2025-02-16 11:06:31 +01:00
dependabot[bot]
d912561bfb Bump numpy from 2.2.2 to 2.2.3 (#456)
Some checks are pending
docker-build / platform-excludes (push) Waiting to run
docker-build / build (push) Blocked by required conditions
docker-build / merge (push) Blocked by required conditions
pre-commit / pre-commit (push) Waiting to run
Run Pytest on Pull Request / test (push) Waiting to run
Bumps [numpy](https://github.com/numpy/numpy) from 2.2.2 to 2.2.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.2.2...v2.2.3)

---
updated-dependencies:
- dependency-name: numpy
  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-02-15 13:32:48 +01:00
dependabot[bot]
5907c94a2e Bump myst-parser from 4.0.0 to 4.0.1 (#455)
Bumps [myst-parser](https://github.com/executablebooks/MyST-Parser) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/executablebooks/MyST-Parser/releases)
- [Changelog](https://github.com/executablebooks/MyST-Parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/executablebooks/MyST-Parser/compare/v4.0.0...v4.0.1)

---
updated-dependencies:
- dependency-name: myst-parser
  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-02-15 13:29:34 +01:00
Bobby Noelte
7b9b58f1e0 Add Markdown linter
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
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-13 12:10:47 +01:00
Dennis
c87bf2e4fc EOF issue in "optimize" documentation 2025-02-13 12:10:47 +01:00
Dennis
7773c4c2c9 Initial "optimize" documentation 2025-02-13 12:10:47 +01:00
Dominique Lasserre
b380624c9f Windows: Fix EOSdash startup Closes #436 #447 (#450)
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
* 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-02-10 00:38:35 +01:00
dependabot[bot]
caed880672 Bump mypy from 1.13.0 to 1.15.0 (#449)
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
Bumps [mypy](https://github.com/python/mypy) from 1.13.0 to 1.15.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.13.0...v1.15.0)

---
updated-dependencies:
- dependency-name: mypy
  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-02-08 01:08:17 +01:00
celle1234
6cc9a5fd44 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-02-08 00:47:21 +01:00
dependabot[bot]
80a4079bbf Bump fastapi[standard] from 0.115.7 to 0.115.8 (#442)
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
Bumps [fastapi[standard]](https://github.com/fastapi/fastapi) from 0.115.7 to 0.115.8.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.115.7...0.115.8)

---
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-02-04 08:30:54 +01:00
dependabot[bot]
cc687b140f Bump python-fasthtml from 0.12.0 to 0.12.1 (#441)
Bumps [python-fasthtml](https://github.com/AnswerDotAI/fasthtml) from 0.12.0 to 0.12.1.
- [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.0...0.12.1)

---
updated-dependencies:
- dependency-name: python-fasthtml
  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-02-04 08:25:09 +01:00
Dominique Lasserre
29cf3a3174 README.md: Add some system requirements (#438)
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
2025-02-01 20:46:00 +01:00
Theo Weiss
837595de56 remove excess double quotes in Makefile (#437) 2025-02-01 16:41:24 +01:00
Normann
1a2da7636b Data prefetch for ems (#418)
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
* Pre-fetch data

* maintanance and extend tests

* comment clean up

* nansum usage (to be save)
2025-01-26 18:29:26 +01:00
dependabot[bot]
774cfd8b65 Bump fastapi[standard] from 0.115.6 to 0.115.7 (#411)
Some checks are pending
docker-build / platform-excludes (push) Waiting to run
docker-build / build (push) Blocked by required conditions
docker-build / merge (push) Blocked by required conditions
pre-commit / pre-commit (push) Waiting to run
Run Pytest on Pull Request / test (push) Waiting to run
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>
2025-01-25 19:35:42 +01:00
dependabot[bot]
9170b5f5cd 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>
2025-01-25 19:32:34 +01:00
dependabot[bot]
e42dd5d4b2 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>
2025-01-25 19:29:00 +01:00
dependabot[bot]
58f077b4ae 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>
2025-01-25 19:24:44 +01:00
146 changed files with 14115 additions and 13529 deletions

View File

@@ -1,8 +1,8 @@
.git/ .git/
.github/ .github/
**/__pycache__/ eos-data/
**/*.pyc mariadb-data/
**/*.egg-info/ test_data/
.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_SERVER__PORT=8503 EOS_PORT=8503
EOS_SERVER__EOSDASH_PORT=8504 EOSDASH_PORT=8504
PYTHON_VERSION=3.12.6 PYTHON_VERSION=3.12.6

View File

@@ -5,7 +5,16 @@
version: 2 version: 2
updates: updates:
# Update dependencies on the main branch
- package-ecosystem: "pip" # See documentation for possible values - package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests directory: "/" # Location of package manifests
schedule: schedule:
interval: "weekly" interval: "weekly"
target-branch: "main" # Target the main branch
# Update dependencies on the feature/config-nested branch
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
target-branch: "feature/config-nested" # Target the specific feature branch

View File

@@ -7,11 +7,13 @@ 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

3
.gitignore vendored
View File

@@ -260,6 +260,3 @@ 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

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: 6.0.0 rev: 5.13.2
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.9.6 rev: v0.6.8
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.15.0' rev: 'v1.13.0'
hooks: hooks:
- id: mypy - id: mypy
additional_dependencies: additional_dependencies:
@@ -33,3 +33,12 @@ repos:
- "pandas-stubs==2.2.3.241009" - "pandas-stubs==2.2.3.241009"
- "numpy==2.1.3" - "numpy==2.1.3"
pass_filenames: false pass_filenames: false
- repo: https://github.com/jackdewinter/pymarkdown
rev: main
hooks:
- id: pymarkdown
files: ^docs/
exclude: ^docs/_generated
args:
- --config=docs/pymarkdown.json
- scan

View File

@@ -6,7 +6,7 @@ The `EOS` project is in early development, therefore we encourage contribution i
## Documentation ## Documentation
Latest development documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/main/). Latest development documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/).
## Bug Reports ## Bug Reports

View File

@@ -3,12 +3,17 @@ 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"
ENV EOS_OUTPUT_DIR="${EOS_DIR}/output" ENV EOS_OUTPUT_DIR="${EOS_DIR}/output"
ENV EOS_CONFIG_DIR="${EOS_DIR}/config" ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
# Overwrite when starting the container in a production environment
ENV EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
WORKDIR ${EOS_DIR} WORKDIR ${EOS_DIR}
RUN adduser --system --group --no-create-home eos \ RUN adduser --system --group --no-create-home eos \
@@ -37,6 +42,7 @@ 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

@@ -17,12 +17,10 @@ help:
@echo " docker-build - Rebuild docker image" @echo " docker-build - Rebuild docker image"
@echo " docs - Generate HTML documentation (in build/docs/html/)." @echo " docs - Generate HTML documentation (in build/docs/html/)."
@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 virtual environment." @echo " run - Run EOS production server in the virtual environment."
@echo " run-dev - Run EOS development server in virtual environment (automatically reloads)." @echo " run-dev - Run EOS development server in the 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 " 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."
@@ -87,19 +85,11 @@ clean: clean-docs
run: run:
@echo "Starting EOS production server, please wait..." @echo "Starting EOS production server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eos .venv/bin/python src/akkudoktoreos/server/eos.py
run-dev: run-dev:
@echo "Starting EOS development server, please wait..." @echo "Starting EOS development server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eos --host localhost --port 8503 --reload true .venv/bin/python src/akkudoktoreos/server/eos.py --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

View File

@@ -8,9 +8,19 @@ Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedoc
See [CONTRIBUTING.md](CONTRIBUTING.md). See [CONTRIBUTING.md](CONTRIBUTING.md).
## System requirements
- Python >= 3.11, < 3.13
- Architecture: amd64, aarch64 (armv8)
- OS: Linux, Windows, macOS
Note: For Python 3.13 some dependencies (e.g. [Pendulum](https://github.com/python-pendulum/Pendulum)) are not yet available on https://pypi.org and have to be manually compiled (a recent [Rust](https://www.rust-lang.org/tools/install) installation is required).
Other architectures (e.g. armv6, armv7) are unsupported for now, because a multitude of dependencies are not available on https://piwheels.org and have to be built manually (a recent Rust installation and [GCC](https://gcc.gnu.org/) are required, Python 3.11 is recommended).
## Installation ## Installation
The project requires Python 3.10 or newer. Official docker images can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos). 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`.
@@ -30,11 +40,11 @@ Windows:
```cmd ```cmd
python -m venv .venv python -m venv .venv
.venv\Scripts\pip install -r requirements.txt .venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pip install -e . .venv\Scripts\pip install -e .
``` ```
Finally, start the EOS server: Finally, start the EOS server to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`):
Linux: Linux:
@@ -50,12 +60,12 @@ Windows:
### Docker ### Docker
Start EOS with following command to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`):
```bash ```bash
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,15 +11,14 @@ 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:
- "${EOS_SERVER__PORT}:${EOS_SERVER__PORT}" # Configure what ports to expose on host
- "${EOS_SERVER__EOSDASH_PORT}:${EOS_SERVER__EOSDASH_PORT}" - "${EOS_PORT}:8503"
- "${EOSDASH_PORT}:8504"

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 provider, then update data with Set LoadAkkudoktor as load_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,8 +91,6 @@ 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`: {
@@ -123,7 +121,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 provider, then update data with Set PVForecastAkkudoktor as pvforecast_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
@@ -153,7 +151,7 @@ Note:
Electricity price charges are added. Electricity price charges are added.
Note: Note:
Set ElecPriceAkkudoktor as provider, then update data with Set ElecPriceAkkudoktor as elecprice_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
@@ -166,127 +164,6 @@ 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)
@@ -313,11 +190,11 @@ Returns:
Fastapi Config Put Fastapi Config Put
``` ```
Update the current config with the provided settings. Write the provided settings into the current settings.
Note that for any setting value that is None or unset, the configuration will fall back to The existing settings are completely overwritten. Note that for any setting
values from other sources such as environment variables, the EOS configuration file, or default value that is None, the configuration will fall back to values from other sources such as
values. environment variables, the EOS configuration file, or default values.
Args: Args:
settings (SettingsEOS): The settings to write into the current settings. settings (SettingsEOS): The settings to write into the current settings.
@@ -326,11 +203,311 @@ Returns:
configuration (ConfigEOS): The current configuration after the write. configuration (ConfigEOS): The current configuration after the write.
``` ```
**Request Body**: **Parameters**:
- `application/json`: { - `server_eos_host` (query, optional): EOS server IP address.
"$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**:
@@ -340,6 +517,25 @@ 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)
@@ -359,14 +555,14 @@ Returns:
--- ---
## POST /v1/config/reset ## POST /v1/config/update
**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) **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)
Fastapi Config Reset Post Fastapi Config Update Post
``` ```
Reset the configuration to the EOS configuration file. Update the configuration from the EOS configuration file.
Returns: Returns:
configuration (ConfigEOS): The current configuration after update. configuration (ConfigEOS): The current configuration after update.
@@ -378,25 +574,28 @@ Returns:
--- ---
## GET /v1/config/{path} ## PUT /v1/config/value
**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) **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)
Fastapi Config Get Key Fastapi Config Value Put
``` ```
Get the value of a nested key or index in the config model. Set the configuration option in the settings.
Args: Args:
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0"). key (str): configuration key
value (Any): configuration value
Returns: Returns:
value (Any): The value of the selected nested key. configuration (ConfigEOS): The current configuration after the write.
``` ```
**Parameters**: **Parameters**:
- `path` (path, required): The nested path to the configuration key (e.g., general/latitude). - `key` (query, required): configuration key
- `value` (query, required): configuration value
**Responses**: **Responses**:
@@ -406,58 +605,6 @@ Returns:
--- ---
## 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`: {
"description": "The value to assign to the specified configuration path.",
"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
---
## PUT /v1/measurement/data ## PUT /v1/measurement/data
**Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put) **Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put)
@@ -674,92 +821,6 @@ 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"
},
{
"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)
@@ -803,32 +864,7 @@ Args:
- `end_datetime` (query, optional): Ending datetime (exclusive). - `end_datetime` (query, optional): Ending datetime (exclusive).
- `interval` (query, optional): Time duration for each interval. Defaults to 1 hour. - `interval` (query, optional): Time duration for each interval.
**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**:

View File

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

BIN
docs/_static/introduction/integration.png vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
docs/_static/introduction/introduction.png vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
docs/_static/introduction/overview.png vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

BIN
docs/_static/optimization_timeframes.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

View File

@@ -1,9 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
# About Akkudoktor EOS
The Energy System Simulation and Optimization System (EOS) provides a comprehensive solution for
simulating and optimizing an energy system based on renewable energy sources. With a focus on
photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements),
heat pumps, electric vehicles, and consideration of electricity price data, this system enables
forecasting and optimization of energy flow and costs over a specified period.

View File

@@ -20,17 +20,22 @@ EOS Architecture
### Configuration ### Configuration
The configuration controls all aspects of EOS: optimization, prediction, measurement, and energy management. The configuration controls all aspects of EOS: optimization, prediction, measurement, and energy
management.
### Energy Management ### Energy Management
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 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 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
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
### Optimization ### Optimization
### Device Simulations ### Device Simulations
Device simulations simulate devices' behavior based on internal logic and predicted data. They provide the data needed for optimization. Device simulations simulate devices' behavior based on internal logic and predicted data. They
provide the data needed for optimization.
### Predictions ### Predictions
@@ -38,7 +43,8 @@ Predictions provide predicted future data to be used by the optimization.
### Measurements ### Measurements
Measurements are utilized to refine predictions using real data from your system, thereby enhancing accuracy. Measurements are utilized to refine predictions using real data from your system, thereby enhancing
accuracy.
### EOS Server ### EOS Server

View File

@@ -7,9 +7,10 @@ management.
## Storing Configuration ## Storing Configuration
EOS stores configuration data in a `nested structure`. Note that configuration changes inside EOS EOS stores configuration data in a **key-value store**, where a `configuration key` refers to the
are updated in memory, meaning all changes will be lost upon restarting the EOS REST server if not unique identifier used to store and retrieve specific configuration data. Note that the key-value
saved to the `EOS configuration file`. store is memory-based, meaning all stored data will be lost upon restarting the EOS REST server if
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.
@@ -24,37 +25,37 @@ Use endpoint `PUT /v1/config/file` to save the current configuration to the
### Load Configuration File ### Load Configuration File
Use endpoint `POST /v1/config/reset` to reset the configuration to the values in the Use endpoint `POST /v1/config/update` to update the configuration from the `EOS configuration file`.
`EOS configuration file`.
## Configuration Sources and Priorities ## Configuration Sources and Priorities
The configuration sources and their priorities are as follows: The configuration sources and their priorities are as follows:
1. **Runtime Config Updates**: Provided during runtime by the REST interface 1. `Settings`: Provided during runtime by the REST interface
2. **Environment Variables**: Defined at startup of the REST server and during runtime 2. `Environment Variables`: Defined at startup of the REST server and during runtime
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`
### Runtime Config Updates ### Settings
The EOS configuration can be updated at runtime. Note that those updates are not persistent Settings are sets of configuration data that take precedence over all other configuration data from
automatically. However it is possible to save the configuration to the `EOS configuration file`. different sources. Note that settings are not persistent. To make the current configuration with the
current settings persistent, save the configuration to the `EOS configuration file`.
Use the following endpoints to change the current runtime configuration: Use the following endpoints to change the current configuration settings:
- `PUT /v1/config`: Update the entire or parts of the configuration. - `PUT /v1/config`: Replaces the entire configuration settings.
- `PUT /v1/config/value`: Sets a specific configuration option.
### Environment Variables ### Environment Variables
All `configuration keys` can be set by environment variables prefixed with `EOS_` and separated by All `configuration keys` can be set by environment variables with the same name. EOS recognizes the
`__` for nested structures. Environment variables are case insensitive. following special environment variables:
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
@@ -65,7 +66,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 `general.config_file_path` configuration key. `GET /v1/config` provides the `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:
@@ -74,15 +75,9 @@ 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 configuration file available in these directories is loaded. If no configuration file is The first available configuration file found in these directories is loaded. If no configuration
found, a default configuration file is created, and the default settings are written to it. The file is found, a default configuration file is created in the platform-specific default directory,
location of the created configuration file follows the same order in which EOS searches for and default settings are loaded into it.
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

@@ -1,4 +1,5 @@
% SPDX-License-Identifier: Apache-2.0 % SPDX-License-Identifier: Apache-2.0
(integration-page)=
# Integration # Integration
@@ -17,18 +18,19 @@ APIs, and online services in creative and practical ways.
Andreas Schmitz uses [Node-RED](https://nodered.org/) as part of his home automation setup. Andreas Schmitz uses [Node-RED](https://nodered.org/) as part of his home automation setup.
### Resources ### Node-Red Resources
- [Installation Guide (German)](https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/) — A detailed guide on integrating an early version of EOS with - [Installation Guide (German)](https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/)
`Node-RED`. \— A detailed guide on integrating an early version of EOS with `Node-RED`.
## Home Assistant ## Home Assistant
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform that [Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform that
emphasizes local control and user privacy. emphasizes local control and user privacy.
### Resources (duetting-solution)=
### Home Assistant Resources
- 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 details can be found in this [discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).
[discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).

View File

@@ -0,0 +1,180 @@
% SPDX-License-Identifier: Apache-2.0
# Introduction
The Energy System Simulation and Optimization System (EOS) provides a comprehensive
solution for simulating and optimizing an energy system based on renewable energy
sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load
management (consumer requirements), heat pumps, electric vehicles, and consideration of
electricity price data, this system enables forecasting and optimization of energy flow
and costs over a specified period.
After successfully installing a PV system with or without battery storage, most owners
first priority is often to charge the electric car with surplus energy in order to use
the electricity generated by the PV system cost-effectively for electromobility.
After initial experiences, the desire to include battery storage and dynamic electricity
prices in the solution soon arises. The market already offers various commercial and
non-commercial solutions for this, such as the popular open source hardware and software
solutions evcc or openWB.
Some solutions take into account the current values of the system such as PV power
output, battery storage charge level or the current electricity price to decide whether
to charge the electric car with PV surplus or from the grid (e.g. openWB), some use
historical consumption values and PV forecast data for their calculations, but leave out
the current electricity prices and charging the battery storage from the power grid
(Predbat). Others are specialiced on working in combination with a specific smart home
solution (e.g. emhass). Still others focus on certain consumers, such as the electric car,
or are currently working on integrating the forecast values (evcc). And some are commercial
devices that require an electrician to install them and expect a certain ecosystem
(e.g. Sunny Home Manager).
The Akkudoktor EOS
- takes into account historical, current and forecast data such as consumption values, PV
forecast data, electricity price forecast, battery storage and electric car charge levels
- the simulation also takes into account the possibility of charging the battery storage
from the grid at low electricity prices
- is not limited to certain consumers, but includes electric cars, heat pumps or more
powerful consumers such as tumble dryers
- is independent of a specific smart home solution and can also be integrated into
self-developed solutions if desired
- is a free and independent open source software solution
![Introdution](../_static/introduction/introduction.png)
The challenge is to charge (electric car) or start the consumers (washing machine, dryer)
at the right time and to do so as cost-efficiently as possible. If PV yield forecast,
battery storage and dynamic electricity price forecasts are included in the calculation,
the possibilities increase, but unfortunately so does the complexity.
The Akkudoktor EOS addresses this challenge by simulating energy flows in the household
based on target values, forecast data and current operating data over a 48-hour
observation period, running through a large number of different scenarios and finally
providing a cost-optimized plan for the current day controlling the relevant consumers.
## Prerequisites
- Technical requirements
- Input data
### Technical requirements
- reasonably fast computer on which EOS is installed
- controllable energy system consisting of photovoltaic system, solar battery storage,
energy intensive consumers that must provide the appropriate interfaces
- integration solution for integrating the energy system and EOS
### Input Data
![Overview](../_static/introduction/overview.png)
The EOS requires various types of data for the simulation:
Forecast data
- PV yield forecast
- Expected household consumption
- Electricity price forecast
- Forecast temperature trend (if heatpump is used)
Basic data and current operating data
- Current charge level of the battery storage
- Value of electricity in the battery storage
- Current charge level of the electric car
- Energy consumption and running time of dishwasher, washing machine and tumble dryer
Target values
- Charge level the electric car should reach in the next few hours
- Consumers to run in the next few hours
There are various service providers available for PV forecasting that calculate forecast
data for a PV system based on the various influencing factors, such as system size,
orientation, location, time of year and weather conditions. EOS also offers a
[PV forecasting service](#prediction-page) which can be used. This service uses
public data in the background.
For the forecast of household consumption EOS provides a standard load curve for an
average day based on annual household consumption that you can fetch via API. This data
was compiled based on data from several households and provides an initial usable basis.
Alternatively your own collected historical data could be used to reflect your personal
consumption behaviour.
## Simulation Results
Based on the input data, the EOS uses a genetic algorithm to create a cost-optimized
schedule for the coming hours from numerous simulations of the overall system.
The plan created contains for each of the coming hours
- Control information
- whether and with what power the battery storage should be charged from the grid
- when the battery storage should be charged via the PV system
- whether discharging the battery storage is permitted or not
- when and with what power the electric car should be charged
- when a household appliance should be activated
- Energy history information
- Total load of the house
- Grid consumption
- Feed-in
- Load of the planned household appliances
- Charge level of the battery storage
- Charge level of the electric car
- Active losses
- Cost information
- Revenue per hour (when fed into the grid)
- Total costs per hour (when drawn from the grid)
- Overall balance (revenue-costs)
- Cost development
If required, the simulation result can also be created and downloaded in graphical
form as a PDF from EOS.
## Integration
The Akkudoktor EOS can be integrated into a wide variety of systems with a variety
of components.
![Integration](../_static/introduction/integration.png)
However, the components are not integrated by the EOS itself, but must be intergrated by
the user using an integration solution and currently requires some effort and technical
know-how.
Any [integration](#integration-page) solution that can act as an intermediary between the
components and the REST API of EOS can be used. One possible solution that enables the
integration of components and EOS is Node-RED. Another solution could be Home Assistant
usings its built in features.
Access to the data and functions of the components can be done in a variety of ways.
Node-RED offers a large number of types of nodes that allow access via the protocols
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.
It becomes easier if a smart home solution like Homa Assistant, openHAB or ioBroker or
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
at a technical level and Node-RED offers nodes for accessing these solutions, so that the
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
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).
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.
## Limitations
The plan calculated by EOS is cost-optimized due to the genetic algorithm used, but not
necessarily cost-optimal, since genetic algorithms do not always find the global optimum,
but usually find good local optima very quickly in a large solution space.
## 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 Forum of Akkudoktor EOS](https://akkudoktor.net/c/der-akkudoktor/eos)
- [Akkudoktor-EOS GitHub Repository](https://github.com/Akkudoktor-EOS/EOS)
- [Latest EOS Documentation](https://akkudoktor-eos.readthedocs.io/en/latest/)

View File

@@ -5,9 +5,9 @@
Measurements are utilized to refine predictions using real data from your system, thereby enhancing Measurements are utilized to refine predictions using real data from your system, thereby enhancing
accuracy. accuracy.
- **Household Load Measurement** - Household Load Measurement
- **Grid Export Measurement** - Grid Export Measurement
- **Grid Import Measurement** - Grid Import Measurement
## Storing Measurements ## Storing Measurements
@@ -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:
- `load0_mr`: Load0 meter reading [kWh] - `measurement_load0_mr`: Load0 meter reading [kWh]
- `load1_mr`: Load1 meter reading [kWh] - `measurement_load1_mr`: Load1 meter reading [kWh]
- `load2_mr`: Load2 meter reading [kWh] - `measurement_load2_mr`: Load2 meter reading [kWh]
- `load3_mr`: Load3 meter reading [kWh] - `measurement_load3_mr`: Load3 meter reading [kWh]
- `load4_mr`: Load4 meter reading [kWh] - `measurement_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'):
- `load0_name`: Name of the load0 source - `measurement_load0_name`: Name of the load0 source
- `load1_name`: Name of the load1 source - `measurement_load1_name`: Name of the load1 source
- `load2_name`: Name of the load2 source - `measurement_load2_name`: Name of the load2 source
- `load3_name`: Name of the load3 source - `measurement_load3_name`: Name of the load3 source
- `load4_name`: Name of the load4 source - `measurement_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:
- `grid_export_mr`: Export to grid meter reading [kWh] - `measurement_grid_export_mr`: Export to grid meter reading [kWh]
- `grid_import_mr`: Import from grid meter reading [kWh] - `measurement_grid_import_mr`: Import from grid meter reading [kWh]
:::{admonition} Todo :::{admonition} Todo
:class: note :class: note

View File

@@ -2,7 +2,199 @@
# Optimization # Optimization
:::{admonition} Todo ## Introduction
:class: note
Describe optimization. The `POST /optimize` API endpoint optimizes your energy management system based on various inputs
::: including electricity prices, battery storage capacity, PV forecast, and temperature data.
## Input Payload
### Sample Request
```json
{
"ems": {
"preis_euro_pro_wh_akku": 0.0007,
"einspeiseverguetung_euro_pro_wh": 0.00007,
"gesamtlast": [500, 500, ..., 500, 500],
"pv_prognose_wh": [300, 0, 0, ..., 2160, 1840],
"strompreis_euro_pro_wh": [0.0003784, 0.0003868, ..., 0.00034102, 0.00033709]
},
"pv_akku": {
"capacity_wh": 12000,
"charging_efficiency": 0.92,
"discharging_efficiency": 0.92,
"max_charge_power_w": 5700,
"initial_soc_percentage": 66,
"min_soc_percentage": 5,
"max_soc_percentage": 100
},
"inverter": {
"max_power_wh": 15500
},
"eauto": {
"capacity_wh": 64000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"max_charge_power_w": 11040,
"initial_soc_percentage": 98,
"min_soc_percentage": 60,
"max_soc_percentage": 100
},
"temperature_forecast": [18.3, 18, ..., 20.16, 19.84],
"start_solution": null
}
```
## Input Parameters
### Energy Management System (EMS)
#### Battery Cost (`preis_euro_pro_wh_akku`)
- Unit: €/Wh
- Purpose: Represents the residual value of energy stored in the battery
- Impact: Lower values encourage battery depletion, higher values preserve charge at the end of the simulation.
#### Feed-in Tariff (`einspeiseverguetung_euro_pro_wh`)
- Unit: €/Wh
- Purpose: Compensation received for feeding excess energy back to the grid
#### Total Load Forecast (`gesamtlast`)
- Unit: W
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
- Format: Array of hourly values
- Note: Exclude optimizable loads (EV charging, battery charging, etc.)
##### Data Sources
1. Standard Load Profile: `GET /v1/prediction/list?key=load_mean` for a standard load profile based
on your yearly consumption.
2. Adjusted Load Profile: `GET /v1/prediction/list?key=load_mean_adjusted` for a combination of a
standard load profile based on your yearly consumption incl. data from last 48h.
#### PV Generation Forecast (`pv_prognose_wh`)
- Unit: W
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
- Format: Array of hourly values
- Data Source: `GET /v1/prediction/series?key=pvforecast_ac_power`
#### Electricity Price Forecast (`strompreis_euro_pro_wh`)
- Unit: €/Wh
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
- Format: Array of hourly values
- Data Source: `GET /v1/prediction/list?key=elecprice_marketprice_wh`
Verify prices against your local tariffs.
### Battery Storage System
#### Configuration
- `capacity_wh`: Total battery capacity in Wh
- `charging_efficiency`: Charging efficiency (0-1)
- `discharging_efficiency`: Discharging efficiency (0-1)
- `max_charge_power_w`: Maximum charging power in W
#### State of Charge (SoC)
- `initial_soc_percentage`: Current battery level (%)
- `min_soc_percentage`: Minimum allowed SoC (%)
- `max_soc_percentage`: Maximum allowed SoC (%)
### Inverter
- `max_power_wh`: Maximum inverter power in Wh
### Electric Vehicle (EV)
- `capacity_wh`: Battery capacity in Wh
- `charging_efficiency`: Charging efficiency (0-1)
- `discharging_efficiency`: Discharging efficiency (0-1)
- `max_charge_power_w`: Maximum charging power in W
- `initial_soc_percentage`: Current charge level (%)
- `min_soc_percentage`: Minimum allowed SoC (%)
- `max_soc_percentage`: Maximum allowed SoC (%)
### Temperature Forecast
- Unit: °C
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
- Format: Array of hourly values
- Data Source: `GET /v1/prediction/list?key=weather_temp_air`
## Output Format
### Sample Response
```json
{
"ac_charge": [0.625, 0, ..., 0.75, 0],
"dc_charge": [1, 1, ..., 1, 1],
"discharge_allowed": [0, 0, 1, ..., 0, 0],
"eautocharge_hours_float": [0.625, 0, ..., 0.75, 0],
"result": {
"Last_Wh_pro_Stunde": [...],
"EAuto_SoC_pro_Stunde": [...],
"Einnahmen_Euro_pro_Stunde": [...],
"Gesamt_Verluste": 1514.96,
"Gesamtbilanz_Euro": 2.51,
"Gesamteinnahmen_Euro": 2.88,
"Gesamtkosten_Euro": 5.39,
"akku_soc_pro_stunde": [...]
}
}
```
### Output Parameters
#### Battery Control
- `ac_charge`: Grid charging schedule (0-1)
- `dc_charge`: DC charging schedule (0-1)
- `discharge_allowed`: Discharge permission (0 or 1)
0 (no charge)
1 (charge with full load)
`ac_charge` multiplied by the maximum charge power of the battery results in the planned charging power.
#### EV Charging
- `eautocharge_hours_float`: EV charging schedule (0-1)
#### Results
The `result` object contains detailed information about the optimization outcome.
The length of the array is between 25 and 48 and starts at the current hour and ends at 23:00 tomorrow.
- `Last_Wh_pro_Stunde`: Array of hourly load values in Wh
- Shows the total energy consumption per hour
- Includes household load, battery charging/discharging, and EV charging
- `EAuto_SoC_pro_Stunde`: Array of hourly EV state of charge values (%)
- Shows the projected EV battery level throughout the optimization period
- `Einnahmen_Euro_pro_Stunde`: Array of hourly revenue values in Euro
- `Gesamt_Verluste`: Total energy losses in Wh
- `Gesamtbilanz_Euro`: Overall financial balance in Euro
- `Gesamteinnahmen_Euro`: Total revenue in Euro
- `Gesamtkosten_Euro`: Total costs in Euro
- `akku_soc_pro_stunde`: Array of hourly battery state of charge values (%)
## Timeframe overview
```{figure} ../_static/optimization_timeframes.png
:alt: Timeframe Overview
Timeframe Overview
```

View File

@@ -1,14 +1,15 @@
% SPDX-License-Identifier: Apache-2.0 % SPDX-License-Identifier: Apache-2.0
(prediction-page)=
# Predictions # Predictions
Predictions, along with simulations and measurements, form the foundation upon which energy Predictions, along with simulations and measurements, form the foundation upon which energy
optimization is executed. In EOS, a standard set of predictions is managed, including: optimization is executed. In EOS, a standard set of predictions is managed, including:
- **Household Load Prediction** - Household Load Prediction
- **Electricity Price Prediction** - Electricity Price Prediction
- **PV Power Prediction** - PV Power Prediction
- **Weather Prediction** - Weather Prediction
## Storing Predictions ## Storing Predictions
@@ -19,14 +20,10 @@ 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 and can be set by prediction type. For example: in the EOS configuration. 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
@@ -60,13 +57,15 @@ A dictionary with the following structure:
#### 2. DateTimeDataFrame #### 2. DateTimeDataFrame
A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html) dataframe with a A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html) dataframe with a
`DatetimeIndex`. Use [pandas.DataFrame.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_json.html#pandas.DataFrame.to_json). `DatetimeIndex`. Use
[pandas.DataFrame.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_json.html#pandas.DataFrame.to_json).
The column name of the data must be the same as the names of the `prediction key`s. The column name of the data must be the same as the names of the `prediction key`s.
#### 3. DateTimeSeries #### 3. DateTimeSeries
A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html) series with a A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html) series with a
`DatetimeIndex`. Use [pandas.Series.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.Series.to_json.html#pandas.Series.to_json). `DatetimeIndex`. Use
[pandas.Series.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.Series.to_json.html#pandas.Series.to_json).
## Adjusted Predictions ## Adjusted Predictions
@@ -75,7 +74,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 `loads` `loadakkudoktor_year_energy`, and finally adjusts the predicted load by the `measurement_loads`
of your system. of your system.
## Prediction Updates ## Prediction Updates
@@ -111,23 +110,21 @@ Prediction keys:
Configuration options: Configuration options:
- `elecprice`: Electricity price configuration. - `elecprice_provider`: Electricity price provider id of provider to be used.
- `provider`: Electricity price provider id of provider to be used.
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net. - `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
- `ElecPriceImport`: Imports from a file or JSON string. - `ElecPriceImport`: Imports from a file or JSON string.
- `charges_kwh`: Electricity price charges (€/kWh). - `elecprice_charges_kwh`: Electricity price charges (€/kWh).
- `provider_settings.import_file_path`: Path to the file to import electricity price forecast data from. - `elecpriceimport_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. - `elecpriceimport_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 `charges_kwh` configuration from Akkudoktor.net. Electricity price charges given in the `elecprice_charges_kwh` configuration
option are added. option are added.
### ElecPriceImport Provider ### ElecPriceImport Provider
@@ -141,11 +138,8 @@ 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 can be given in the <project:#prediction-import-providers>. The data source must be given in the
`import_file_path` or `import_json` configuration option. `elecpriceimport_file_path` or `elecpriceimport_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/ElecPriceImport` endpoint.
## Load Prediction ## Load Prediction
@@ -157,16 +151,14 @@ Prediction keys:
Configuration options: Configuration options:
- `load`: Load configuration. - `load_provider`: Load provider id of provider to be used.
- `provider`: Load provider id of provider to be used.
- `LoadAkkudoktor`: Retrieves from local database. - `LoadAkkudoktor`: Retrieves from local database.
- `LoadImport`: Imports from a file or JSON string. - `LoadImport`: Imports from a file or JSON string.
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh). - `loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
- `provider_settings.loadimport_file_path`: Path to the file to import load forecast data from. - `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. - `loadimport_json`: JSON string, dictionary of load forecast value lists.
### LoadAkkudoktor Provider ### LoadAkkudoktor Provider
@@ -187,12 +179,9 @@ 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 can be given in the `loadimport_file_path` <project:#prediction-import-providers>. The data source must 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:
@@ -202,72 +191,111 @@ Prediction keys:
Configuration options: Configuration options:
- `general`: General configuration. - `pvforecast_provider`: PVForecast provider id of provider to be used.
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
- `pvforecast`: PV forecast configuration.
- `provider`: PVForecast provider id of provider to be used.
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net. - `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
- `PVForecastImport`: Imports from a file or JSON string. - `PVForecastImport`: Imports from a file or JSON string.
- `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking. - `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
- `planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
- `planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `planes[].peakpower`: Nominal power of PV system in kW. - `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
- `planes[].pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. Clockwise from north (north=0, east=90, south=180, west=270).
- `planes[].mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. - `pvforecast<0..5>_userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `planes[].loss`: Sum of PV system losses in percent - `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW.
- `planes[].trackingtype`: 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. - `pvforecast<0..5>_pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `planes[].optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking. - `pvforecast<0..5>_mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing
- `planes[].optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. and 'building' for building-integrated.
- `planes[].albedo`: Proportion of the light hitting the ground that it reflects back. - `pvforecast<0..5>_loss`: Sum of PV system losses in percent
- `planes[].module_model`: Model of the PV modules of this plane. - `pvforecast<0..5>_trackingtype`: Type of suntracking. 0=fixed,
- `planes[].inverter_model`: Model of the inverter of this plane. 1=single horizontal axis aligned north-south,
- `planes[].inverter_paco`: AC power rating of the inverter. [W] 2=two-axis tracking,
- `planes[].modules_per_string`: Number of the PV modules of the strings of this plane. 3=vertical axis tracking,
- `planes[].strings_per_inverter`: Number of the strings of the inverter of this plane. 4=single horizontal axis aligned east-west,
- `provider_settings.import_file_path`: Path to the file to import PV forecast data from. 5=single inclined axis aligned north-south.
- `provider_settings.import_json`: JSON string, dictionary of PV forecast value lists. - `pvforecast<0..5>_optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast<0..5>_optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast<0..5>_albedo`: Proportion of the light hitting the ground that it reflects back.
- `pvforecast<0..5>_module_model`: Model of the PV modules of this plane.
- `pvforecast<0..5>_inverter_model`: Model of the inverter of this plane.
- `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W]
- `pvforecast<0..5>_modules_per_string`: Number of the PV modules of the strings of this plane.
- `pvforecast<0..5>_strings_per_inverter`: Number of the strings of the inverter of this plane.
- `pvforecastimport_file_path`: Path to the file to import PV forecast data from.
- `pvforecastimport_json`: JSON string, dictionary of PV forecast value lists.
------ ---
Some of the planes configuration options directly follow the [PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en) nomenclature. Some of the configuration options directly follow the
[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**: 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 dependence varies between different types of PV modules. At the moment we can estimate the losses due to temperature and irradiance effects for the following types of modules: crystalline silicon cells; thin film modules made from CIS or CIGS and thin film modules made from Cadmium Telluride (CdTe). 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
due to temperature and irradiance effects for the following types of modules: crystalline silicon
cells; thin film modules made from CIS or CIGS and thin film modules made from Cadmium Telluride
(CdTe).
For other technologies (especially various amorphous technologies), this correction cannot be calculated here. If you choose one of the first three options here the calculation of performance will take into account the temperature dependence of the performance of the chosen technology. If you choose the other option (other/unknown), the calculation will assume a loss of 8% of power due to temperature effects (a generic value which has found to be reasonable for temperate climates). For other technologies (especially various amorphous technologies), this correction cannot be
calculated here. If you choose one of the first three options here the calculation of performance
will take into account the temperature dependence of the performance of the chosen technology. If
you choose the other option (other/unknown), the calculation will assume a loss of 8% of power due
to temperature effects (a generic value which has found to be reasonable for temperate climates).
PV power output also depends on the spectrum of the solar radiation. PVGIS can calculate how the variations of the spectrum of sunlight affects the overall energy production from a PV system. At 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. PV power output also depends on the spectrum of the solar radiation. PVGIS can calculate how the
variations of the spectrum of sunlight affects the overall energy production from a PV system. At
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.
- `peakpower` - `pvforecast<0..5>_peakpower`
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 the array, at an array temperature of 25°C. The peak power should be entered in kilowatt-peak (kWp). If you do not know the declared peak power of your modules but instead know the area of the modules and the declared conversion efficiency (in percent), you can calculate the peak power as power = area * efficiency / 100. 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
the array, at an array temperature of 25°C. The peak power should be entered in kilowatt-peak (kWp).
If you do not know the declared peak power of your modules but instead know the area of the modules
and the declared conversion efficiency (in percent), you can calculate the peak power as
power = area \* efficiency / 100.
Bifacial modules: PVGIS doesn't make specific calculations for bifacial modules at present. Users who wish to explore the possible benefits of this technology can input the power value for Bifacial Nameplate Irradiance. This can also be can also be estimated from the front side peak power P_STC 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 installations or for modules mounting on a N-S axis i.e. facing E-W. Bifacial modules: PVGIS doesn't make specific calculations for bifacial modules at present. Users
who wish to explore the possible benefits of this technology can input the power value for Bifacial
Nameplate Irradiance. This can also be can also be estimated from the front side peak power P_STC
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
installations or for modules mounting on a N-S axis i.e. facing E-W.
- `loss` - `pvforecast<0..5>_loss`
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 several causes for this loss, such as losses in cables, power inverters, dirt (sometimes snow) on the modules and so on. Over the years the modules also tend to lose a bit of their power, so the average yearly output over the lifetime of the system will be a few percent lower than the output in the first years. 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
several causes for this loss, such as losses in cables, power inverters, dirt (sometimes snow) on
the modules and so on. Over the years the modules also tend to lose a bit of their power, so the
average yearly output over the lifetime of the system will be a few percent lower than the output
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 will be different (maybe due to a really high-efficiency inverter) you may reduce this value a little. 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.
- `mountingplace` - `pvforecast<0..5>_mountingplace`
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 movement of air behind the modules is restricted, the modules can get considerably hotter (up to 15°C at 1000W/m2 of sunlight). 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
movement of air behind the modules is restricted, the modules can get considerably hotter
(up to 15°C at 1000W/m2 of sunlight).
In PVGIS there are two possibilities: free-standing, meaning that the modules are mounted on a rack with air flowing freely behind the modules; and building- integrated, which means that the modules are completely built into the structure of the wall or roof of a building, with no air movement behind the modules. In PVGIS there are two possibilities: free-standing, meaning that the modules are mounted on a rack
with air flowing freely behind the modules; and building- integrated, which means that the modules
are completely built into the structure of the wall or roof of a building, with no air movement
behind the modules.
Some types of mounting are in between these two extremes, for instance if the modules are mounted on 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. Some types of mounting are in between these two extremes, for instance if the modules are mounted on
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.
- `userhorizon` - `pvforecast<0..5>_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
@@ -277,82 +305,71 @@ represent equal angular distance around the horizon. For instance, if you have 3
point is due north, the next is 10 degrees east of north, and so on, until the last point, 10 point is due north, the next is 10 degrees east of north, and so on, until the last point, 10
degrees west of north. degrees west of north.
------ ---
Most of the planes configuration options are in line with the [PVLib](https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html) definition for PVGIS data. Most of the configuration options are in line with the
[PVLib](https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html) definition for PVGIS data.
Detailed definitions from **PVLib** for PVGIS data. Detailed definitions from **PVLib** for PVGIS data.
- `surface_tilt`: - `pvforecast<0..5>_surface_tilt`:
Tilt angle from horizontal plane. Tilt angle from horizontal plane.
- `surface_azimuth` - `pvforecast<0..5>_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.
------ ---
### PVForecastAkkudoktor Provider ### PVForecastAkkudoktor Provider
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 prediction configuration options of the PV system must be set: The following general configuration options of the PV system must be set:
- `general.latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)" - `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
- `general.longitude`: Longitude in decimal degrees, within -180 to 180 (°) - `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
For each plane of the PV system the following configuration options must be set: For each plane `<0..5>` of the PV system the following configuration options must be set:
- `pvforecast.planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking. - `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast.planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
- `pvforecast.planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast.planes[].inverter_paco`: AC power rating of the inverter. [W] - `pvforecast<0..5>_userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast.planes[].peakpower`: Nominal power of PV system in kW. - `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W]
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW.
Example: Example:
```Python ```Python
{ {
"general": {
"latitude": 50.1234, "latitude": 50.1234,
"longitude": 9.7654, "longitude": 9.7654,
}, "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,
}
]
}
} }
``` ```
@@ -368,11 +385,8 @@ The prediction keys for the PV forecast data are:
- `pvforecast_dc_power`: Total AC power (W). - `pvforecast_dc_power`: Total AC 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 can be given in the <project:#prediction-import-providers>. The data source must be given in the
`import_file_path` or `import_json` configuration option. `pvforecastimport_file_path` or `pvforecastimport_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/PVForecastImport` endpoint.
## Weather Prediction ## Weather Prediction
@@ -403,16 +417,14 @@ Prediction keys:
Configuration options: Configuration options:
- `weather`: General weather configuration. - `weather_provider`: Load provider id of provider to be used.
- `provider`: Load provider id of provider to be used. - `BrightSky`: Retrieves from [BrightSky](https://api.brightsky.dev).
- `ClearOutside`: Retrieves from [ClearOutside](https://clearoutside.com/forecast).
- `BrightSky`: Retrieves from https://api.brightsky.dev.
- `ClearOutside`: Retrieves from https://clearoutside.com/forecast.
- `LoadImport`: Imports from a file or JSON string. - `LoadImport`: Imports from a file or JSON string.
- `provider_settings.import_file_path`: Path to the file to import weatherforecast data from. - `weatherimport_file_path`: Path to the file to import weatherforecast data from.
- `provider_settings.import_json`: JSON string, dictionary of weather forecast value lists. - `weatherimport_json`: JSON string, dictionary of weather forecast value lists.
### BrightSky Provider ### BrightSky Provider
@@ -469,7 +481,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 weather forecast data are: The prediction keys for the PV 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)
@@ -495,8 +507,5 @@ The prediction keys for the weather 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 can be given in the <project:#prediction-import-providers>. The data source must be given in the
`import_file_path` or `import_json` configuration option. `weatherimport_file_path` or `pvforecastimport_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/WeatherImport` endpoint.

View File

@@ -99,7 +99,6 @@ 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

@@ -75,37 +75,53 @@ This project uses the `EOS.config.json` file to manage configuration settings.
### Default Configuration ### Default Configuration
A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values. A default configuration file `default.config.json` is provided. This file contains all the necessary
configuration keys with their default values.
### Custom Configuration ### Custom Configuration
Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`. Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`.
- If the directory specified by `EOS_DIR` contains an existing `EOS.config.json` file, the application will use this configuration file. - If the directory specified by `EOS_DIR` contains an existing `EOS.config.json` file, the
- If the `EOS.config.json` file does not exist in the specified directory, the `default.config.json` file will be copied to the directory as `EOS.config.json`. application will use this configuration file.
- If the `EOS.config.json` file does not exist in the specified directory, the `default.config.json`
file will be copied to the directory as `EOS.config.json`.
### Configuration Updates ### Configuration Updates
If the configuration keys in the `EOS.config.json` file are missing or different from those in `default.config.json`, they will be automatically updated to match the default settings, ensuring that all required keys are present. If the configuration keys in the `EOS.config.json` file are missing or different from those in
`default.config.json`, they will be automatically updated to match the default settings, ensuring
that all required keys are present.
## Classes and Functionalities ## Classes and Functionalities
This project uses various classes to simulate and optimize the components of an energy system. Each class represents a specific aspect of the system, as described below: This project uses various classes to simulate and optimize the components of an energy system. Each
class represents a specific aspect of the system, as described below:
- `Battery`: Simulates a battery storage system, including capacity, state of charge, and now charge and discharge losses. - `Battery`: Simulates a battery storage system, including capacity, state of charge, and now
charge and discharge losses.
- `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and historical generation data. - `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and
historical generation data.
- `Load`: Models the load requirements of a household or business, enabling the prediction of future energy demand. - `Load`: Models the load requirements of a household or business, enabling the prediction of future
energy demand.
- `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various operating conditions. - `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various
operating conditions.
- `Strompreis`: Provides information on electricity prices, enabling optimization of energy consumption and generation based on tariff information. - `Strompreis`: Provides information on electricity prices, enabling optimization of energy
consumption and generation based on tariff information.
- `EMS`: The Energy Management System (EMS) coordinates the interaction between the various components, performs optimization, and simulates the operation of the entire energy system. - `EMS`: The Energy Management System (EMS) coordinates the interaction between the various
components, performs optimization, and simulates the operation of the entire energy system.
These classes work together to enable a detailed simulation and optimization of the energy system. For each class, specific parameters and settings can be adjusted to test different scenarios and strategies. These classes work together to enable a detailed simulation and optimization of the energy system.
For each class, specific parameters and settings can be adjusted to test different scenarios and
strategies.
### Customization and Extension ### Customization and Extension
Each class is designed to be easily customized and extended to integrate additional functions or improvements. For example, new methods can be added for more accurate modeling of PV system or battery behavior. Developers are invited to modify and extend the system according to their needs. Each class is designed to be easily customized and extended to integrate additional functions or
improvements. For example, new methods can be added for more accurate modeling of PV system or
battery behavior. Developers are invited to modify and extend the system according to their needs.

View File

@@ -8,12 +8,32 @@
```{toctree} ```{toctree}
:maxdepth: 2 :maxdepth: 2
:caption: 'Contents:' :caption: Overview
akkudoktoreos/introduction.md
```
```{toctree}
:maxdepth: 2
:caption: Tutorials
welcome.md
akkudoktoreos/about.md
develop/getting_started.md develop/getting_started.md
```
```{toctree}
:maxdepth: 2
:caption: How-To Guides
develop/CONTRIBUTING.md develop/CONTRIBUTING.md
```
```{toctree}
:maxdepth: 2
:caption: Reference
akkudoktoreos/architecture.md akkudoktoreos/architecture.md
akkudoktoreos/configuration.md akkudoktoreos/configuration.md
akkudoktoreos/optimization.md akkudoktoreos/optimization.md
@@ -22,9 +42,10 @@ akkudoktoreos/measurement.md
akkudoktoreos/integration.md akkudoktoreos/integration.md
akkudoktoreos/serverapi.md akkudoktoreos/serverapi.md
akkudoktoreos/api.rst akkudoktoreos/api.rst
``` ```
# Indices and tables ## Indices and tables
- {ref}`genindex` - {ref}`genindex`
- {ref}`modindex` - {ref}`modindex`

20
docs/pymarkdown.json Normal file
View File

@@ -0,0 +1,20 @@
{
"plugins": {
"md007": {
"enabled": true,
"code_block_line_length" : 160
},
"md013": {
"enabled": true,
"line_length" : 120
},
"md041": {
"enabled": false
}
},
"extensions": {
"front-matter" : {
"enabled" : true
}
}
}

View File

@@ -1,12 +1,12 @@
% SPDX-License-Identifier: Apache-2.0 % SPDX-License-Identifier: Apache-2.0
# Welcome to the EOS documentation! # Welcome to the EOS documentation
This documentation is continuously written. It is edited via text files in the This documentation is continuously written. It is edited via text files in the
[Markdown/ Markedly Structured Text](https://myst-parser.readthedocs.io/en/latest/index.html) [Markdown/ Markedly Structured Text](https://myst-parser.readthedocs.io/en/latest/index.html)
markup language and then compiled into a static website/ offline document using the open source tool markup language and then compiled into a static website/ offline document using the open source tool
[Sphinx](https://www.sphinx-doc.org) and will someday land on [Sphinx](https://www.sphinx-doc.org) and is available on
[Read the Docs](https://akkudoktoreos.readthedocs.io/en/latest/index.html). [Read the Docs](https://akkudoktor-eos.readthedocs.io/en/latest/).
You can contribute to EOS's documentation by opening You can contribute to EOS's documentation by opening
[GitHub issues](https://github.com/Akkudoktor-EOS/EOS/issues) [GitHub issues](https://github.com/Akkudoktor-EOS/EOS/issues)

12046
openapi.json

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ authors = [
description = "This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period." description = "This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period."
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}
requires-python = ">=3.10" requires-python = ">=3.11"
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",

View File

@@ -1,13 +1,14 @@
-r requirements.txt -r requirements.txt
gitpython==3.1.44 gitpython==3.1.44
myst-parser==4.0.0 linkify-it-py==2.0.3
sphinx==8.1.3 myst-parser==4.0.1
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.4 pytest==8.3.5
pytest-cov==6.0.0 pytest-cov==6.0.0
pytest-xprocess==1.0.2 pytest-xprocess==1.0.2
pre-commit pre-commit
mypy==1.13.0 mypy==1.15.0
types-requests==2.32.0.20241016 types-requests==2.32.0.20250306
pandas-stubs==2.2.3.241126 pandas-stubs==2.2.3.250308

View File

@@ -1,13 +1,8 @@
cachebox==4.4.2 numpy==2.2.4
numpy==2.2.2 numpydantic==1.6.8
numpydantic==1.6.7 matplotlib==3.10.1
matplotlib==3.10.0 fastapi[standard]==0.115.11
fastapi[standard]==0.115.7 python-fasthtml==0.12.4
python-fasthtml==0.12.0
MonsterUI==0.0.29
markdown-it-py==3.0.0
mdit-py-plugins==0.4.2
bokeh==3.6.3
uvicorn==0.34.0 uvicorn==0.34.0
scikit-learn==1.6.1 scikit-learn==1.6.1
timezonefinder==6.5.8 timezonefinder==6.5.8
@@ -15,10 +10,7 @@ deap==1.4.2
requests==2.32.3 requests==2.32.3
pandas==2.2.3 pandas==2.2.3
pendulum==3.0.0 pendulum==3.0.0
platformdirs==4.3.6 platformdirs==4.3.7
psutil==6.1.1 pvlib==0.12.0
pvlib==0.11.2
pydantic==2.10.6 pydantic==2.10.6
statsmodels==0.14.4 statsmodels==0.14.4
pydantic-settings==2.7.0
linkify-it-py==2.0.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="utf-8", newline=None) as f: with open(args.input_file, "r", encoding="utf8") 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="utf-8", newline="\n") as f: with open(args.output_file, "w", encoding="utf8") as f:
f.write(extracted_content) f.write(extracted_content)
else: else:
# Write to std output # Write to std output

View File

@@ -2,280 +2,132 @@
"""Utility functions for Configuration specification generation.""" """Utility functions for Configuration specification generation."""
import argparse import argparse
import json
import os
import sys import sys
import textwrap
from pathlib import Path
from typing import Any, Union
from pydantic.fields import ComputedFieldInfo, FieldInfo from akkudoktoreos.config.config import get_config
from pydantic_core import PydanticUndefined
from akkudoktoreos.config.config import ConfigEOS, GeneralSettings, get_config
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.utils.docs import get_model_structure_from_examples
logger = get_logger(__name__) logger = get_logger(__name__)
config_eos = get_config()
documented_types: set[PydanticBaseModel] = set() # Fixed set of prefixes to filter configuration values and their respective titles
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = 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",
}
global_config_dict: dict[str, Any] = dict() # 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 get_title(config: PydanticBaseModel) -> str: def generate_config_table_md(configs, title):
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:
config (PydanticBaseModel): PydanticBaseModel configuration definition. configs (dict): Configuration values with keys and their descriptions.
prefix (str): Prefix for table entries. title (str): Title for the table.
Returns: Returns:
str: The markdown table as a string. str: The markdown table as a string.
""" """
table = "" if not configs:
if toplevel: return ""
title = get_title(config)
heading_level = "###" if extra_config else "##" table = f"## {title}\n\n"
env_header = "" table += ":::{table} " + f"{title}\n:widths: 10 10 5 5 30\n:align: left\n\n"
env_header_underline = "" table += "| Name | Type | Read-Only | Default | Description |\n"
env_width = "" table += "| ---- | ---- | --------- | ------- | ----------- |\n"
if not extra_config: for name, config in sorted(configs.items()):
env_header = "| Environment Variable " type_name = config["type"]
env_header_underline = "| -------------------- " if type_name.startswith("typing."):
env_width = "20 " type_name = type_name[len("typing.") :]
table += f"| `{config['name']}` | `{type_name}` | `{config['read-only']}` | `{config['default']}` | {config['description']} |\n"
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 "-"
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 = "| "
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 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
)
return table return table
def generate_config_md(config_eos: ConfigEOS) -> str: def generate_config_md() -> 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.
""" """
# Fix file path for general settings to not show local/test file path configs = {}
GeneralSettings._config_file_path = Path( config_keys = config_eos.config_keys
"/home/user/.config/net.akkudoktoreos.net/EOS.config.json" config_keys_read_only = config_eos.config_keys_read_only
) for config_key in config_keys:
GeneralSettings._config_folder_path = config_eos.general.config_file_path.parent config = {}
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 tables for each top level config # Generate table for general configuration names
for field_name, field_info in config_eos.model_fields.items(): general_configs = {k: v for k, v in configs.items() if k in GENERAL_CONFIGS}
field_type = field_info.annotation for k in general_configs.keys():
markdown += generate_config_table_md( del configs[k] # Remove general configs from the main configs dictionary
field_type, [field_name], f"EOS_{field_name.upper()}__", True markdown += generate_config_table_md(general_configs, "General Configuration Values")
)
# Full config non_prefixed_configs = {k: v for k, v in configs.items()}
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"
# Assure there is no double \n at end of file # Generate tables for each prefix (sorted by value) and remove prefixed configs from the main dictionary
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"
@@ -293,15 +145,12 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
config_eos = get_config()
try: try:
config_md = generate_config_md(config_eos) config_md = generate_config_md()
if os.name == "nt":
config_md = config_md.replace("127.0.0.1", "0.0.0.0").replace("\\\\", "/")
if args.output_file: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: with open(args.output_file, "w", encoding="utf8") as f:
f.write(config_md) f.write(config_md)
else: else:
# Write to std output # Write to std output
@@ -309,8 +158,7 @@ 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)
# keep throwing error to debug potential problems (e.g. invalid examples) sys.exit(1)
raise e
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -16,7 +16,6 @@ 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
@@ -38,11 +37,6 @@ 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"
return openapi_spec return openapi_spec
@@ -58,11 +52,9 @@ def main():
try: try:
openapi_spec = generate_openapi() openapi_spec = generate_openapi()
openapi_spec_str = json.dumps(openapi_spec, indent=2) openapi_spec_str = json.dumps(openapi_spec, indent=2)
if os.name == "nt":
openapi_spec_str = openapi_spec_str.replace("127.0.0.1", "0.0.0.0")
if args.output_file: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: with open(args.output_file, "w", encoding="utf8") 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,7 +3,6 @@
import argparse import argparse
import json import json
import os
import sys import sys
import git import git
@@ -285,11 +284,9 @@ 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", "0.0.0.0")
if args.output_file: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: with open(args.output_file, "w", encoding="utf8") as f:
f.write(openapi_md) f.write(openapi_md)
else: else:
# Write to std output # Write to std output

View File

@@ -30,63 +30,42 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
""" """
# Make a config # Make a config
settings = { settings = {
"general": { # -- General --
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52, "latitude": 52.52,
"longitude": 13.405, "longitude": 13.405,
}, # -- Predictions --
"prediction": {
"hours": 48,
"historic_hours": 24,
},
# PV Forecast # PV Forecast
"pvforecast": { "pvforecast_provider": "PVForecastAkkudoktor",
"provider": "PVForecastAkkudoktor", "pvforecast0_peakpower": 5.0,
"planes": [ "pvforecast0_surface_azimuth": -10,
{ "pvforecast0_surface_tilt": 7,
"peakpower": 5.0, "pvforecast0_userhorizon": [20, 27, 22, 20],
"surface_azimuth": -10, "pvforecast0_inverter_paco": 10000,
"surface_tilt": 7, "pvforecast1_peakpower": 4.8,
"userhorizon": [20, 27, 22, 20], "pvforecast1_surface_azimuth": -90,
"inverter_paco": 10000, "pvforecast1_surface_tilt": 7,
}, "pvforecast1_userhorizon": [30, 30, 30, 50],
{ "pvforecast1_inverter_paco": 10000,
"peakpower": 4.8, "pvforecast2_peakpower": 1.4,
"surface_azimuth": -90, "pvforecast2_surface_azimuth": -40,
"surface_tilt": 7, "pvforecast2_surface_tilt": 60,
"userhorizon": [30, 30, 30, 50], "pvforecast2_userhorizon": [60, 30, 0, 30],
"inverter_paco": 10000, "pvforecast2_inverter_paco": 2000,
}, "pvforecast3_peakpower": 1.6,
{ "pvforecast3_surface_azimuth": 5,
"peakpower": 1.4, "pvforecast3_surface_tilt": 45,
"surface_azimuth": -40, "pvforecast3_userhorizon": [45, 25, 30, 60],
"surface_tilt": 60, "pvforecast3_inverter_paco": 1400,
"userhorizon": [60, 30, 0, 30], "pvforecast4_peakpower": None,
"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": { "weather_provider": "ClearOutside",
"provider": "ClearOutside",
},
# Electricity Price Forecast # Electricity Price Forecast
"elecprice": { "elecprice_provider": "ElecPriceAkkudoktor",
"provider": "ElecPriceAkkudoktor",
},
# Load Forecast # Load Forecast
"load": { "load_provider": "LoadAkkudoktor",
"provider": "LoadAkkudoktor",
"provider_settings": {
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh "loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
},
},
# -- Simulations -- # -- Simulations --
} }
config_eos = get_config() config_eos = get_config()
@@ -150,20 +129,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,
} }
@@ -304,20 +283,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,
} }
@@ -351,9 +330,7 @@ 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( config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48})
{"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,47 +16,32 @@ prediction_eos = get_prediction()
def config_pvforecast() -> dict: def config_pvforecast() -> dict:
"""Configure settings for PV forecast.""" """Configure settings for PV forecast."""
settings = { settings = {
"general": { "prediction_hours": 48,
"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
@@ -64,15 +49,10 @@ def config_pvforecast() -> dict:
def config_weather() -> dict: def config_weather() -> dict:
"""Configure settings for weather forecast.""" """Configure settings for weather forecast."""
settings = { settings = {
"general": { "prediction_hours": 48,
"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
@@ -80,15 +60,10 @@ 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 = {
"general": { "prediction_hours": 48,
"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
@@ -96,14 +71,10 @@ def config_elecprice() -> dict:
def config_load() -> dict: def config_load() -> dict:
"""Configure settings for load forecast.""" """Configure settings for load forecast."""
settings = { settings = {
"general": { "prediction_hours": 48,
"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
@@ -121,40 +92,30 @@ 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()
forecast = "pvforecast" settings["pvforecast_provider"] = provider_id
elif provider_id in ("BrightSky", "ClearOutside"): elif provider_id in ("BrightSky", "ClearOutside"):
settings = config_weather() settings = config_weather()
forecast = "weather" settings["weather_provider"] = provider_id
elif provider_id in ("ElecPriceAkkudoktor",): elif provider_id in ("ElecPriceAkkudoktor",):
settings = config_elecprice() settings = config_elecprice()
forecast = "elecprice" settings["elecprice_provider"] = provider_id
elif provider_id in ("LoadAkkudoktor",): elif provider_id in ("LoadAkkudoktor",):
settings = config_elecprice() settings = config_elecprice()
forecast = "load" settings["loadakkudoktor_year_energy"] = 1000
settings["load"]["loadakkudoktor_year_energy"] = 1000 settings["load_provider"] = provider_id
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,36 +12,30 @@ Key features:
import os import os
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar, Optional, Type from typing import Any, ClassVar, List, Optional
from platformdirs import user_config_dir, user_data_dir from platformdirs import user_config_dir, user_data_dir
from pydantic import Field, computed_field from pydantic import Field, ValidationError, 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.decorators import classproperty
from akkudoktoreos.core.emsettings import EnergyManagementCommonSettings
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.logsettings import LoggingCommonSettings from akkudoktoreos.core.logsettings import LoggingCommonSettings
from akkudoktoreos.core.pydantic import access_nested_value, merge_models from akkudoktoreos.devices.devices import DevicesCommonSettings
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__) logger = get_logger(__name__)
@@ -65,173 +59,61 @@ def get_absolute_path(
return None return None
class GeneralSettings(SettingsBaseModel): class ConfigCommonSettings(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.
Validators:
validate_latitude (float): Ensures `latitude` is within the range -90 to 90.
validate_longitude (float): Ensures `longitude` is within the range -180 to 180.
"""
_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.", examples=[None, "/home/eos/data"] default=None, description="Path to EOS data directory."
) )
data_output_subpath: Optional[Path] = Field( data_output_subpath: Optional[Path] = Field(
default="output", description="Sub-path for the EOS output data directory." "output", description="Sub-path for the EOS output data directory."
) )
latitude: Optional[float] = Field( data_cache_subpath: Optional[Path] = Field(
default=52.52, "cache", description="Sub-path for the EOS cache data directory."
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 config_folder_path(self) -> Optional[Path]: def data_cache_path(self) -> Optional[Path]:
"""Path to EOS configuration directory.""" """Compute data_cache_path based on data_folder_path."""
return self._config_folder_path return get_absolute_path(self.data_folder_path, self.data_cache_subpath)
@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(BaseSettings): class SettingsEOS(
"""Settings for all EOS. ConfigCommonSettings,
LoggingCommonSettings,
DevicesCommonSettings,
MeasurementCommonSettings,
OptimizationCommonSettings,
PredictionCommonSettings,
ElecPriceCommonSettings,
ElecPriceImportCommonSettings,
LoadCommonSettings,
LoadAkkudoktorCommonSettings,
LoadImportCommonSettings,
PVForecastCommonSettings,
PVForecastImportCommonSettings,
WeatherCommonSettings,
WeatherImportCommonSettings,
ServerCommonSettings,
UtilsCommonSettings,
):
"""Settings for all EOS."""
Used by updating the configuration with specific settings only. pass
"""
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 SettingsEOSDefaults(SettingsEOS): class ConfigEOS(SingletonMixin, 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
@@ -261,6 +143,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
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.
@@ -271,7 +155,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
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
``` ```
""" """
@@ -283,120 +167,111 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
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"
@classmethod _settings: ClassVar[Optional[SettingsEOS]] = None
def settings_customise_sources( _file_settings: ClassVar[Optional[SettingsEOS]] = None
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
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.
This method determines the sources for application configuration settings, including _config_folder_path: Optional[Path] = None
environment variables, dotenv files and JSON configuration files. _config_file_path: Optional[Path] = None
It ensures that a default configuration file exists and creates one if necessary.
Args: # Computed fields
settings_cls (Type[BaseSettings]): The Pydantic BaseSettings class for which sources are customized. @computed_field # type: ignore[prop-decorator]
init_settings (PydanticBaseSettingsSource): The initial settings source, typically passed at runtime. @property
env_settings (PydanticBaseSettingsSource): Settings sourced from environment variables. def config_folder_path(self) -> Optional[Path]:
dotenv_settings (PydanticBaseSettingsSource): Settings sourced from a dotenv file. """Path to EOS configuration directory."""
file_secret_settings (PydanticBaseSettingsSource): Unused (needed for parent class interface). return self._config_folder_path
Returns: @computed_field # type: ignore[prop-decorator]
tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied. @property
def config_file_path(self) -> Optional[Path]:
"""Path to EOS configuration file."""
return self._config_file_path
Behavior: @computed_field # type: ignore[prop-decorator]
1. Checks for the existence of a JSON configuration file in the expected location. @property
2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a def config_default_file_path(self) -> Path:
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 cls.package_root_path.joinpath("data/default.config.json") return self.package_root_path.joinpath("data/default.config.json")
@classproperty @computed_field # type: ignore[prop-decorator]
def package_root_path(cls) -> Path: @property
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()
def __init__(self, *args: Any, **kwargs: Any) -> None: # Computed fields
@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.
""" """
if hasattr(self, "_initialized"): super().__init__()
return self.from_config_file()
self._setup(self, *args, **kwargs) self.update()
def _setup(self, *args: Any, **kwargs: Any) -> None: @property
"""Re-initialize global settings.""" def settings(self) -> Optional[SettingsEOS]:
# Assure settings base knows EOS configuration """Returns global settings for EOS.
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()
def merge_settings(self, settings: SettingsEOS) -> None: Settings generally provide configuration for EOS and are typically set only once.
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 the `settings` is not a `SettingsEOS` instance. ValueError: If settings are already set and `force` is not True or
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}'.") raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.")
self.merge_settings_from_dict(settings.model_dump(exclude_none=True, exclude_unset=True)) if ConfigEOS._settings is None or force:
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.
@@ -414,109 +289,141 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
Example: Example:
>>> config = get_config() >>> config = get_config()
>>> new_data = {"prediction": {"hours": 24}, "server": {"port": 8000}} >>> new_data = {"prediction_hours": 24, "server_eos_port": 8000}
>>> config.merge_settings_from_dict(new_data) >>> config.merge_settings_from_dict(new_data)
""" """
self._setup(**merge_models(self, data)) # Create new settings instance with reset optional fields and merged data
settings = SettingsEOS.from_dict(data)
self.merge_settings(settings)
def reset_settings(self) -> None: def reset_settings(self) -> None:
"""Reset all changed settings to environment/config file defaults. """Reset all available settings.
This functions basically deletes the settings provided before. This functions basically deletes the settings provided before.
""" """
self._setup() ConfigEOS._settings = None
def set_config_value(self, path: str, value: Any) -> None:
"""Set a configuration value based on the provided path.
Supports string paths (with '/' separators) or sequence paths (list/tuple).
Trims leading and trailing '/' from string paths.
Args:
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
value (Any]): The value to set.
"""
access_nested_value(self, path, True, value)
def get_config_value(self, path: str) -> Any:
"""Get a configuration value based on the provided path.
Supports string paths (with '/' separators) or sequence paths (list/tuple).
Trims leading and trailing '/' from string paths.
Args:
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
Returns:
Any: The retrieved value.
"""
return access_nested_value(self, path, False)
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 data_dir := self.general.data_folder_path: if self.settings and (data_dir := self.settings.data_folder_path):
try: try:
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
self.general.data_folder_path = data_dir self.data_folder_path = data_dir
return return
except Exception as e: except:
logger.warning(f"Could not setup data dir: {e}") pass
# From EOS_DIR env # From EOS_DIR env
if env_dir := os.getenv(self.EOS_DIR): 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.general.data_folder_path = data_dir self.data_folder_path = data_dir
return return
except Exception as e: except:
logger.warning(f"Could not setup data dir: {e}") pass
# 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.general.data_folder_path = data_dir self.data_folder_path = data_dir
return return
except Exception as e: except:
logger.warning(f"Could not setup data dir: {e}") pass
# Current working directory # Current working directory
data_dir = Path.cwd() data_dir = Path.cwd()
self.general.data_folder_path = data_dir self.data_folder_path = data_dir
@classmethod def _get_config_file_path(self) -> tuple[Path, bool]:
def _get_config_file_path(cls) -> tuple[Path, bool]:
"""Finds the a valid configuration file or returns the desired path for a new config file. """Finds the a valid configuration file or returns 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 directory and if there is already a config file there
""" """
config_dirs = [] config_dirs = []
env_base_dir = os.getenv(cls.EOS_DIR) env_base_dir = os.getenv(self.EOS_DIR)
env_config_dir = os.getenv(cls.EOS_CONFIG_DIR) env_config_dir = os.getenv(self.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"Environment config dir: '{env_dir}'") logger.debug(f"Envionment 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(cls.APP_NAME, cls.APP_AUTHOR))) config_dirs.append(Path(user_config_dir(self.APP_NAME)))
config_dirs.append(Path.cwd()) config_dirs.append(Path.cwd())
for cdir in config_dirs: for cdir in config_dirs:
cfile = cdir.joinpath(cls.CONFIG_FILE_NAME) cfile = cdir.joinpath(self.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(cls.CONFIG_FILE_NAME), False return config_dirs[0].joinpath(self.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.
@@ -526,24 +433,77 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
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.general.config_file_path: if not self.config_file_path:
raise ValueError("Configuration file path unknown.") raise ValueError("Configuration file path unknown.")
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out: with self.config_file_path.open("w", encoding=self.ENCODING) as f_out:
json_str = super().model_dump_json() try:
json_str = super().to_json()
# Write to file
f_out.write(json_str) 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. Current settings. 1. Settings.
2. Environment variables. 2. Environment variables.
3. EOS configuration file. 3. EOS configuration file.
4. Field default constants. 4. Current configuration.
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._setup(**self.model_dump()) self._update_data_folder_path()
for key in self.model_fields:
setattr(self, key, self._config_value(key))
def get_config() -> ConfigEOS: def get_config() -> ConfigEOS:

View File

@@ -1,12 +1,13 @@
"""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.
# EOS configuration - set by ConfigEOS Note:
config: ClassVar[Any] = None Settings property names shall be disjunctive to all existing settings' property names.
"""
pass

View File

@@ -1,32 +0,0 @@
"""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

@@ -265,14 +265,6 @@ 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

@@ -953,44 +953,6 @@ 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.
@@ -1148,7 +1110,7 @@ class DataProvider(SingletonMixin, DataSequence):
To be implemented by derived classes. To be implemented by derived classes.
""" """
raise NotImplementedError() return self.provider_id() == self.config.abstract_provider
@abstractmethod @abstractmethod
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
@@ -1159,11 +1121,6 @@ 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,
@@ -1267,14 +1224,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
@@ -1503,7 +1460,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 interval # Try dictionary with special keys start_datetime and intervall
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())
@@ -1563,7 +1520,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", encoding="utf-8", newline=None) as import_file: with import_file_path.open("r") 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
@@ -1638,11 +1595,6 @@ 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.
@@ -1845,88 +1797,6 @@ 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

@@ -1,47 +0,0 @@
from collections.abc import Callable
from typing import Any, Optional
from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
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:
AssertionError: 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
assert self.fget is not None
return self.fget(owner_cls)

View File

@@ -6,20 +6,19 @@ 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.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 compare_datetimes, to_datetime from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.utils import NumpyEncoder from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__) 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."
) )
@@ -108,7 +107,7 @@ class SimulationResult(ParametersBaseModel):
return NumpyEncoder.convert_numpy(field)[0] return NumpyEncoder.convert_numpy(field)[0]
class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel): class EnergieManagementSystem(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,
@@ -117,33 +116,16 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
# 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 EnergyManagement._start_datetime is None: if EnergieManagementSystem._start_datetime is None:
EnergyManagement.set_start_datetime() EnergieManagementSystem.set_start_datetime()
return EnergyManagement._start_datetime return EnergieManagementSystem._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)
@@ -187,14 +169,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
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: EnergyManagementParameters, parameters: EnergieManagementSystemParameters,
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,
@@ -216,9 +193,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
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:
@@ -261,83 +238,26 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
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.optimization.hours is None: if self.config.optimisation_hours is None:
error_msg = "Optimization hours unknown." error_msg = "Optimisation 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.
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()
if EnergyManagement._last_datetime is None:
# Never run before
try:
# Try to run a first energy management. May fail due to config incomplete.
self.run()
# Remember energy run datetime.
EnergyManagement._last_datetime = current_datetime
except Exception as e:
message = f"EOS init: {e}"
logger.error(message)
return
if self.config.ems.interval is None or self.config.ems.interval == float("nan"):
# No Repetition
return
if (
compare_datetimes(current_datetime, self._last_datetime).time_diff
< self.config.ems.interval
):
# Wait for next run
return
try:
self.run()
except Exception as e:
message = f"EOS run: {e}"
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
>= self.config.ems.interval
):
EnergyManagement._last_datetime.add(seconds=self.config.ems.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.
@@ -515,9 +435,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
# Initialize the Energy Management System, it is a singleton. # Initialize the Energy Management System, it is a singleton.
ems = EnergyManagement() ems = EnergieManagementSystem()
def get_ems() -> EnergyManagement: def get_ems() -> EnergieManagementSystem:
"""Gets the EOS Energy Management System.""" """Gets the EOS Energy Management System."""
return ems return ems

View File

@@ -1,26 +0,0 @@
"""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

@@ -52,10 +52,6 @@ def get_logger(
# Create a logger with the specified name # Create a logger with the specified name
logger = pylogging.getLogger(name) logger = pylogging.getLogger(name)
logger.propagate = True logger.propagate = True
# This is already supported by pydantic-settings in LoggingCommonSettings, however in case
# loading the config itself fails and to set the level before we load the config, we set it here manually.
if logging_level is None and (env_level := os.getenv("EOS_LOGGING__LEVEL")) is not None:
logging_level = env_level
if logging_level is not None: if logging_level is not None:
level = logging_str_to_level(logging_level) level = logging_str_to_level(logging_level)
logger.setLevel(level) logger.setLevel(level)

View File

@@ -4,6 +4,7 @@ Kept in an extra module to avoid cyclic dependencies on package import.
""" """
import logging import logging
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
@@ -13,20 +14,21 @@ from akkudoktoreos.core.logabc import logging_str_to_level
class LoggingCommonSettings(SettingsBaseModel): class LoggingCommonSettings(SettingsBaseModel):
"""Logging Configuration.""" """Common settings for logging."""
level: Optional[str] = Field( logging_level_default: Optional[str] = Field(
default=None, default=None, description="EOS default logging level."
description="EOS default logging level.",
examples=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"],
) )
# Validators # Validators
@field_validator("level", mode="after") @field_validator("logging_level_default", mode="after")
@classmethod @classmethod
def set_default_logging_level(cls, value: Optional[str]) -> Optional[str]: def set_default_logging_level(cls, value: Optional[str]) -> Optional[str]:
if isinstance(value, str) and value.upper() == "NONE": if isinstance(value, str) and value.upper() == "NONE":
value = None value = None
if value is None and (env_level := os.getenv("EOS_LOGGING_LEVEL")) is not None:
# Take default logging level from special environment variable
value = env_level
if value is None: if value is None:
return None return None
level = logging_str_to_level(value) level = logging_str_to_level(value)
@@ -36,7 +38,7 @@ class LoggingCommonSettings(SettingsBaseModel):
# Computed fields # Computed fields
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def root_level(self) -> str: def logging_level_root(self) -> str:
"""Root logger logging level.""" """Root logger logging level."""
level = logging.getLogger().getEffectiveLevel() level = logging.getLogger().getEffectiveLevel()
level_name = logging.getLevelName(level) level_name = logging.getLevelName(level)

View File

@@ -14,7 +14,6 @@ Key Features:
import json import json
import re import re
from copy import deepcopy
from typing import Any, Dict, List, Optional, Type, Union from typing import Any, Dict, List, Optional, Type, Union
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -36,85 +35,6 @@ from pydantic import (
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
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
def access_nested_value(
model: BaseModel, path: str, setter: bool, value: Optional[Any] = None
) -> Any:
"""Get or set a nested model value based on the provided path.
Supports string paths (with '/' separators) or sequence paths (list/tuple).
Trims leading and trailing '/' from string paths.
Args:
model (BaseModel): The model object for partial assignment.
path (str): The path to the model key (e.g., "key1/key2/key3" or key1/key2/0).
setter (bool): True to set value at path, False to return value at path.
value (Optional[Any]): The value to set.
Returns:
Any: The retrieved value if acting as a getter, or None if setting a value.
"""
path_elements = path.strip("/").split("/")
cfg: Any = model
parent: BaseModel = model
model_key: str = ""
for i, key in enumerate(path_elements):
is_final_key = i == len(path_elements) - 1
if isinstance(cfg, list):
try:
idx = int(key)
if is_final_key:
if not setter: # Getter
return cfg[idx]
else: # Setter
new_list = list(cfg)
new_list[idx] = value
# Trigger validation
setattr(parent, model_key, new_list)
else:
cfg = cfg[idx]
except ValidationError as e:
raise ValueError(f"Error updating model: {e}") from e
except (ValueError, IndexError) as e:
raise IndexError(f"Invalid list index at {path}: {key}") from e
elif isinstance(cfg, BaseModel):
parent = cfg
model_key = key
if is_final_key:
if not setter: # Getter
return getattr(cfg, key)
else: # Setter
try:
# Verification also if nested value is provided opposed to just setattr
# Will merge partial assignment
cfg = cfg.__pydantic_validator__.validate_assignment(cfg, key, value)
except Exception as e:
raise ValueError(f"Error updating model: {e}") from e
else:
cfg = getattr(cfg, key)
else:
raise KeyError(f"Key '{key}' not found in model.")
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]): class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
"""Custom type adapter for Pendulum DateTime fields.""" """Custom type adapter for Pendulum DateTime fields."""
@@ -193,16 +113,9 @@ class PydanticBaseModel(BaseModel):
return value return value
# Override Pydantics serialization for all DateTime fields # Override Pydantics serialization for all DateTime fields
def model_dump( def model_dump(self, *args: Any, **kwargs: Any) -> dict:
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)
@@ -257,10 +170,6 @@ 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.
@@ -437,10 +346,6 @@ 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,

View File

@@ -1,2 +1,113 @@
{ {
"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,14 +1,11 @@
from typing import Any, Optional from typing import Any, Optional
import numpy as np import numpy as np
from pydantic import Field, field_validator from pydantic import BaseModel, Field, field_validator
from akkudoktoreos.core.logging import get_logger 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__) logger = get_logger(__name__)
@@ -25,26 +22,14 @@ 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, examples=[42]) return Field(default=0, ge=0, le=100, description=description)
def discharging_efficiency_field(default_value: float) -> float: class BaseBatteryParameters(ParametersBaseModel):
return Field( """Base class for battery parameters with fields for capacity, efficiency, and state of charge."""
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, gt=0, description="An integer representing the capacity of the battery in watt-hours."
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,
@@ -52,7 +37,12 @@ class BaseBatteryParameters(DeviceParameters):
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 = discharging_efficiency_field(0.88) discharging_efficiency: float = Field(
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)."
@@ -62,7 +52,6 @@ class BaseBatteryParameters(DeviceParameters):
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,
@@ -77,19 +66,17 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
class ElectricVehicleParameters(BaseBatteryParameters): class ElectricVehicleParameters(BaseBatteryParameters):
"""Battery Electric Vehicle Device Simulation Configuration.""" """Parameters specific to an electric vehicle (EV)."""
device_id: str = Field(description="ID of electric vehicle", examples=["ev1"]) discharging_efficiency: float = 1.0
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(DeviceOptimizeResult): class ElectricVehicleResult(BaseModel):
"""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)."
) )
@@ -97,6 +84,7 @@ class ElectricVehicleResult(DeviceOptimizeResult):
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.")
@@ -115,18 +103,67 @@ class ElectricVehicleResult(DeviceOptimizeResult):
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__(self, parameters: Optional[BaseBatteryParameters] = None): def __init__(
self.parameters: Optional[BaseBatteryParameters] = None self,
super().__init__(parameters) parameters: Optional[BaseBatteryParameters] = None,
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"
def _setup(self) -> None: self.parameters = parameters
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."""
assert self.parameters is not None if self.initialised:
return
if self.provider_id:
# Setup from configuration
self.capacity_wh = getattr(self.config, f"{self.prefix}_capacity")
self.initial_soc_percentage = getattr(self.config, f"{self.prefix}_initial_soc")
self.hours = self.total_hours # TODO where does that come from?
self.charging_efficiency = getattr(self.config, f"{self.prefix}_charging_efficiency")
self.discharging_efficiency = getattr(
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.capacity_wh = self.parameters.capacity_wh
self.initial_soc_percentage = self.parameters.initial_soc_percentage self.initial_soc_percentage = self.parameters.initial_soc_percentage
self.charging_efficiency = self.parameters.charging_efficiency self.charging_efficiency = self.parameters.charging_efficiency
self.discharging_efficiency = self.parameters.discharging_efficiency self.discharging_efficiency = self.parameters.discharging_efficiency
self.max_charge_power_w = self.parameters.max_charge_power_w
# Only assign for storage battery # Only assign for storage battery
self.min_soc_percentage = ( self.min_soc_percentage = (
self.parameters.min_soc_percentage self.parameters.min_soc_percentage
@@ -134,11 +171,13 @@ class Battery(DeviceBase):
else 0 else 0
) )
self.max_soc_percentage = self.parameters.max_soc_percentage 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.parameters.max_charge_power_w is not None: if self.max_charge_power_w is 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)
@@ -146,10 +185,11 @@ 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,42 +1,307 @@
from typing import Optional from typing import Any, ClassVar, Dict, Optional, Union
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.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.devices.settings import DevicesCommonSettings from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__) 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):
def __init__(self, settings: Optional[DevicesCommonSettings] = None): # Results of the devices simulation and
if hasattr(self, "_initialized"): # insights into various parameters over the entire forecast period.
return # -----------------------------------------------------------------
super().__init__() last_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
if settings is None: default=None, description="The load in watt-hours per hour."
settings = self.config.devices )
if settings is None: eauto_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
return default=None, description="The state of charge of the EV for each hour."
)
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.",
)
# initialize devices # Computed fields
if settings.batteries is not None: @computed_field # type: ignore[prop-decorator]
for battery_params in settings.batteries: @property
self.add_device(Battery(battery_params)) def total_balance_euro(self) -> float:
if settings.inverters is not None: """The total balance of revenues minus costs in euros."""
for inverter_params in settings.inverters: return self.total_revenues_euro - self.total_costs_euro
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))
self.post_setup() @computed_field # type: ignore[prop-decorator]
@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)
def post_setup(self) -> None: @computed_field # type: ignore[prop-decorator]
for device in self.devices.values(): @property
device.post_setup() def total_costs_euro(self) -> float:
"""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.

View File

@@ -1,45 +1,22 @@
"""Abstract and base classes for devices.""" """Abstract and base classes for devices."""
from enum import Enum from typing import Optional
from typing import Optional, Type
from pendulum import DateTime from pendulum import DateTime
from pydantic import Field, computed_field from pydantic import ConfigDict, 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.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__) 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):
"""A mixin to manage start, end datetimes for devices data. """A mixin to manage start, end datetimes for devices data.
@@ -51,16 +28,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 `hours`. """Compute the end datetime based on the `start_datetime` and `prediction_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(
@@ -91,92 +68,33 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
return int(duration.total_hours()) return int(duration.total_hours())
class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin): class DeviceBase(DevicesStartEndMixin, PredictionMixin):
"""Base class for device simulations. """Base class for device simulations.
Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
`prediction`) and EOS device registry (attribute `devices`). `prediction`).
Behavior: Note:
- Several initialization phases (setup, post_setup): Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
- 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.
""" """
def __init__(self, parameters: Optional[DeviceParameters] = None): # Disable validation on assignment to speed up simulation runs.
self.device_id: str = "<invalid>" model_config = ConfigDict(
self.parameters: Optional[DeviceParameters] = None validate_assignment=False,
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): class DevicesBase(DevicesStartEndMixin, PredictionMixin, PydanticBaseModel):
"""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.
""" """
def __init__(self) -> None: # Disable validation on assignment to speed up simulation runs.
super().__init__() model_config = ConfigDict(
self.devices: dict[str, "DeviceBase"] = dict() validate_assignment=False,
)
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
assert device.device_id not in self.devices, 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

@@ -4,24 +4,20 @@ import numpy as np
from pydantic import Field from pydantic import Field
from akkudoktoreos.core.logging import get_logger 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__) logger = get_logger(__name__)
class HomeApplianceParameters(DeviceParameters): class HomeApplianceParameters(ParametersBaseModel):
"""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],
) )
@@ -29,15 +25,46 @@ 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,
): ):
self.parameters: Optional[HomeApplianceParameters] = None # Configuration initialisation
super().__init__(parameters) self.provider_id = provider_id
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
def _setup(self) -> None: self.initialised = False
assert self.parameters is not None # 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:
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.duration_h = self.parameters.duration_h self.initialised = True
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,7 +1,6 @@
import logging
from typing import List, Sequence from typing import List, Sequence
from akkudoktoreos.core.logging import get_logger
class Heatpump: class Heatpump:
MAX_HEAT_OUTPUT = 5000 MAX_HEAT_OUTPUT = 5000
@@ -19,10 +18,10 @@ 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, hours: int): def __init__(self, max_heat_output: int, prediction_hours: int):
self.max_heat_output = max_heat_output self.max_heat_output = max_heat_output
self.hours = hours self.prediction_hours = prediction_hours
self.log = get_logger(__name__) 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.
@@ -118,9 +117,9 @@ class Heatpump:
"""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.hours: if len(temperatures) != self.prediction_hours:
raise ValueError( raise ValueError(
f"The temperature array must contain exactly {self.hours} entries, " f"The temperature array must contain exactly {self.prediction_hours} entries, "
"one for each hour of the day." "one for each hour of the day."
) )

View File

@@ -1,48 +1,64 @@
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field
from scipy.interpolate import RegularGridInterpolator
from akkudoktoreos.core.logging import get_logger 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__) logger = get_logger(__name__)
class InverterParameters(DeviceParameters): class InverterParameters(ParametersBaseModel):
"""Inverter Device Simulation Configuration.""" max_power_wh: float = Field(gt=0)
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,
): ):
self.parameters: Optional[InverterParameters] = None # Configuration initialisation
super().__init__(parameters) self.provider_id = provider_id
self.prefix = "<invalid>"
def _setup(self) -> None: if self.provider_id == "GenericInverter":
assert self.parameters is not None self.prefix = "inverter"
if self.parameters.battery_id is None: # Parameter initialisiation
self.parameters = parameters
if battery 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.self_consumption_predictor = get_eos_load_interpolator() self.battery = battery # Connection to a battery object
self.max_power_wh = ( self.self_consumption_predictor = self_consumption_predictor
self.parameters.max_power_wh
) # Maximum power that the inverter can handle
def _post_setup(self) -> None: self.initialised = False
assert self.parameters is not None # Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
self.battery = self.devices.get_device_by_id(self.parameters.battery_id) if self.parameters is not None:
self.setup()
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

View File

@@ -1,27 +0,0 @@
from typing import Optional
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.devices.battery import BaseBatteryParameters
from akkudoktoreos.devices.generic import HomeApplianceParameters
from akkudoktoreos.devices.inverter import InverterParameters
logger = get_logger(__name__)
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

@@ -23,22 +23,20 @@ logger = get_logger(__name__)
class MeasurementCommonSettings(SettingsBaseModel): class MeasurementCommonSettings(SettingsBaseModel):
"""Measurement Configuration.""" measurement_load0_name: Optional[str] = Field(
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"]
) )
load1_name: Optional[str] = Field( measurement_load1_name: Optional[str] = Field(
default=None, description="Name of the load1 source", examples=[None] default=None, description="Name of the load1 source (e.g. 'Household', 'Heat Pump')"
) )
load2_name: Optional[str] = Field( measurement_load2_name: Optional[str] = Field(
default=None, description="Name of the load2 source", examples=[None] default=None, description="Name of the load2 source (e.g. 'Household', 'Heat Pump')"
) )
load3_name: Optional[str] = Field( measurement_load3_name: Optional[str] = Field(
default=None, description="Name of the load3 source", examples=[None] default=None, description="Name of the load3 source (e.g. 'Household', 'Heat Pump')"
) )
load4_name: Optional[str] = Field( measurement_load4_name: Optional[str] = Field(
default=None, description="Name of the load4 source", examples=[None] default=None, description="Name of the load4 source (e.g. 'Household', 'Heat Pump')"
) )
@@ -50,42 +48,42 @@ class MeasurementDataRecord(DataRecord):
""" """
# Single loads, to be aggregated to total load # Single loads, to be aggregated to total load
load0_mr: Optional[float] = Field( measurement_load0_mr: Optional[float] = Field(
default=None, ge=0, description="Load0 meter reading [kWh]", examples=[40421] default=None, ge=0, description="Load0 meter reading [kWh]"
) )
load1_mr: Optional[float] = Field( measurement_load1_mr: Optional[float] = Field(
default=None, ge=0, description="Load1 meter reading [kWh]", examples=[None] default=None, ge=0, description="Load1 meter reading [kWh]"
) )
load2_mr: Optional[float] = Field( measurement_load2_mr: Optional[float] = Field(
default=None, ge=0, description="Load2 meter reading [kWh]", examples=[None] default=None, ge=0, description="Load2 meter reading [kWh]"
) )
load3_mr: Optional[float] = Field( measurement_load3_mr: Optional[float] = Field(
default=None, ge=0, description="Load3 meter reading [kWh]", examples=[None] default=None, ge=0, description="Load3 meter reading [kWh]"
) )
load4_mr: Optional[float] = Field( measurement_load4_mr: Optional[float] = Field(
default=None, ge=0, description="Load4 meter reading [kWh]", examples=[None] default=None, ge=0, description="Load4 meter reading [kWh]"
) )
max_loads: ClassVar[int] = 5 # Maximum number of loads that can be set measurement_max_loads: ClassVar[int] = 5 # Maximum number of loads that can be set
grid_export_mr: Optional[float] = Field( measurement_grid_export_mr: Optional[float] = Field(
default=None, ge=0, description="Export to grid meter reading [kWh]", examples=[1000] default=None, ge=0, description="Export to grid meter reading [kWh]"
) )
grid_import_mr: Optional[float] = Field( measurement_grid_import_mr: Optional[float] = Field(
default=None, ge=0, description="Import from grid meter reading [kWh]", examples=[1000] default=None, ge=0, description="Import from grid meter reading [kWh]"
) )
# Computed fields # Computed fields
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def loads(self) -> List[str]: def measurement_loads(self) -> List[str]:
"""Compute a list of active loads.""" """Compute a list of active loads."""
active_loads = [] active_loads = []
# Loop through loadx # Loop through measurement_loadx
for i in range(self.max_loads): for i in range(self.measurement_max_loads):
load_attr = f"load{i}_mr" load_attr = f"measurement_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):
@@ -105,14 +103,9 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
) )
topics: ClassVar[List[str]] = [ topics: ClassVar[List[str]] = [
"load", "measurement_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:
@@ -150,16 +143,11 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
if topic not in self.topics: if topic not in self.topics:
return None return None
topic_keys = [ topic_keys = [key for key in self.config.config_keys if key.startswith(topic)]
key for key in self.config.measurement.model_fields.keys() if key.startswith(topic)
]
key = None key = None
if topic == "load": if topic == "measurement_load":
for config_key in topic_keys: for config_key in topic_keys:
if ( if config_key.endswith("_name") and getattr(self.config, config_key) == name:
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
@@ -255,9 +243,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 load<x>_mr # Loop through measurement_load<x>_mr
for i in range(self.record_class().max_loads): for i in range(self.record_class().measurement_max_loads):
key = f"load{i}_mr" key = f"measurement_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,5 +1,7 @@
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
@@ -12,7 +14,7 @@ from akkudoktoreos.core.coreabc import (
DevicesMixin, DevicesMixin,
EnergyManagementSystemMixin, EnergyManagementSystemMixin,
) )
from akkudoktoreos.core.ems import EnergyManagementParameters, SimulationResult from akkudoktoreos.core.ems import EnergieManagementSystemParameters, SimulationResult
from akkudoktoreos.core.logging import get_logger 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 (
@@ -23,13 +25,14 @@ 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__) logger = get_logger(__name__)
class OptimizationParameters(ParametersBaseModel): class OptimizationParameters(ParametersBaseModel):
ems: EnergyManagementParameters ems: EnergieManagementSystemParameters
pv_akku: Optional[SolarPanelBatteryParameters] pv_akku: Optional[SolarPanelBatteryParameters]
inverter: Optional[InverterParameters] inverter: Optional[InverterParameters]
eauto: Optional[ElectricVehicleParameters] eauto: Optional[ElectricVehicleParameters]
@@ -109,8 +112,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
@@ -120,7 +123,7 @@ 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 == "DEBUG": elif logger.level == logging.DEBUG:
self.fix_seed = random.randint(1, 100000000000) self.fix_seed = random.randint(1, 100000000000)
random.seed(self.fix_seed) random.seed(self.fix_seed)
@@ -177,23 +180,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
) )
@@ -209,13 +212,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
@@ -248,7 +251,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:
@@ -270,13 +273,12 @@ 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
@@ -388,7 +390,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)
@@ -450,7 +452,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
@@ -499,7 +501,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,)
@@ -567,26 +569,30 @@ 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
) )
# TODO: Refactor device setup phase out # 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
self.devices.reset() sc = SelfConsumptionProbabilityInterpolator(
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(parameters.pv_akku) akku = Battery(
self.devices.add_device(akku) parameters.pv_akku,
akku.set_charge_per_hour(np.full(self.config.prediction.hours, 1)) hours=self.config.prediction_hours,
)
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,
) )
self.devices.add_device(eauto) eauto.set_charge_per_hour(np.full(self.config.prediction_hours, 1))
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
) )
@@ -597,22 +603,20 @@ 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

@@ -9,19 +9,21 @@ logger = get_logger(__name__)
class OptimizationCommonSettings(SettingsBaseModel): class OptimizationCommonSettings(SettingsBaseModel):
"""General Optimization Configuration. """Base configuration for optimization settings.
Attributes: Attributes:
hours (int): Number of hours for optimizations. optimization_hours (int): Number of hours for optimizations.
""" """
hours: Optional[int] = Field( optimization_hours: Optional[int] = Field(
default=48, ge=0, description="Number of hours into the future for optimizations." default=24, ge=0, description="Number of hours into the future for optimizations."
) )
penalty: Optional[int] = Field(default=10, description="Penalty factor used in optimization.") optimization_penalty: Optional[int] = Field(
default=10, description="Penalty factor used in optimization."
)
ev_available_charge_rates_percent: Optional[List[float]] = Field( optimization_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,21 +3,12 @@ from typing import Optional
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
class ElecPriceCommonSettings(SettingsBaseModel): class ElecPriceCommonSettings(SettingsBaseModel):
"""Electricity Price Prediction Configuration.""" elecprice_provider: Optional[str] = Field(
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"],
) )
charges_kwh: Optional[float] = Field( elecprice_charges_kwh: Optional[float] = Field(
default=None, ge=0, description="Electricity price charges (€/kWh).", examples=[0.21] default=None, ge=0, description="Electricity price charges (€/kWh)."
)
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
) )

View File

@@ -49,15 +49,15 @@ class ElecPriceProvider(PredictionProvider):
electricity price_provider (str): Prediction provider for electricity price. electricity price_provider (str): Prediction provider for electricity price.
Attributes: Attributes:
hours (int, optional): The number of hours into the future for which predictions are generated. prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
historic_hours (int, optional): The number of past hours for which historical data is retained. prediction_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 `hours`. calculated based on `start_datetime` and `prediction_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 `historic_hours`. based on `start_datetime` and `prediction_historic_hours`.
""" """
# overload # overload
@@ -71,4 +71,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

@@ -14,10 +14,10 @@ import requests
from pydantic import ValidationError from pydantic import ValidationError
from statsmodels.tsa.holtwinters import ExponentialSmoothing from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
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__) logger = get_logger(__name__)
@@ -54,11 +54,11 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
hours (int, optional): Number of hours in the future for the forecast. prediction_hours (int, optional): Number of hours in the future for the forecast.
historic_hours (int, optional): Number of past hours for retaining data. prediction_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 `hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -108,13 +108,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
# 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.general.timezone}" url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}"
response = requests.get(url) response = requests.get(url)
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.general.timezone) self.update_datetime = to_datetime(in_timezone=self.config.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,16 +125,18 @@ 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(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray: def _predict_ets(
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(hours) return model.forecast(prediction_hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray: def _predict_median(self, history: np.ndarray, prediction_hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history) clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history)) return np.full(prediction_hours, np.median(clean_history))
def _update_data( def _update_data(
self, force_update: Optional[bool] = False self, force_update: Optional[bool] = False
@@ -153,14 +155,14 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
# 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 charges_kwh in wh # Get elecprice_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.general.timezone) orig_datetime = to_datetime(value.start, in_timezone=self.config.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
@@ -181,23 +183,27 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
assert highest_orig_datetime # mypy fix assert highest_orig_datetime # mypy fix
# 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_hours = int( needed_prediction_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_hours <= 0: if needed_prediction_hours <= 0:
logger.warning( logger.warning(
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}" 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}"
) # 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(history, seasonal_periods=168, hours=needed_hours) prediction = self._predict_ets(
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(history, seasonal_periods=24, hours=needed_hours) prediction = self._predict_ets(
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, hours=needed_hours) prediction = self._predict_median(history, prediction_hours=needed_prediction_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

@@ -22,22 +22,21 @@ 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."""
import_file_path: Optional[Union[str, Path]] = Field( elecpriceimport_file_path: Optional[Union[str, Path]] = Field(
default=None, default=None, description="Path to the file to import elecprice data from."
description="Path to the file to import elecprice data from.",
examples=[None, "/path/to/prices.json"],
) )
import_json: Optional[str] = Field( elecpriceimport_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("import_file_path", mode="after") @field_validator("elecpriceimport_file_path", mode="after")
@classmethod @classmethod
def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]: def validate_elecpriceimport_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):
@@ -63,15 +62,7 @@ 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.elecprice.provider_settings is None: if self.config.elecpriceimport_file_path is not None:
logger.debug(f"{self.provider_id()} data update without provider settings.") self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice")
return if self.config.elecpriceimport_json is not None:
if self.config.elecprice.provider_settings.import_file_path: self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice")
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,8 +6,6 @@ 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):
@@ -69,17 +67,5 @@ class SelfConsumptionProbabilityInterpolator:
# return self_consumption_rate # return self_consumption_rate
class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin): # Test the function
def __init__(self) -> None: # print(calculate_self_consumption(1000, 1200))
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,26 +1,18 @@
"""Load forecast module for load predictions.""" """Load forecast module for load predictions."""
from typing import Optional, Union from typing import 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 from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
logger = get_logger(__name__) logger = get_logger(__name__)
class LoadCommonSettings(SettingsBaseModel): class LoadCommonSettings(SettingsBaseModel):
"""Load Prediction Configuration.""" """Common settings for loaod forecast providers."""
provider: Optional[str] = Field( load_provider: Optional[str] = Field(
default=None, default=None, description="Load provider id of provider to be used."
description="Load provider id of provider to be used.",
examples=["LoadAkkudoktor"],
)
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
Field(default=None, description="Provider settings", examples=[None])
) )

View File

@@ -33,18 +33,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:
provider (str): Prediction provider for load. load_provider (str): Prediction provider for load.
Attributes: Attributes:
hours (int, optional): The number of hours into the future for which predictions are generated. prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
historic_hours (int, optional): The number of past hours for which historical data is retained. prediction_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 `hours`. calculated based on `start_datetime` and `prediction_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 `historic_hours`. based on `start_datetime` and `prediction_historic_hours`.
""" """
# overload # overload
@@ -58,4 +58,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

@@ -17,7 +17,7 @@ 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).", examples=[40421] default=None, description="Yearly energy consumption (kWh)."
) )
@@ -91,9 +91,7 @@ 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 = ( data_year_energy = profile_data * self.config.loadakkudoktor_year_energy * 1000
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)
@@ -111,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
@@ -129,4 +127,4 @@ class LoadAkkudoktor(LoadProvider):
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.general.timezone) self.update_datetime = to_datetime(in_timezone=self.config.timezone)

View File

@@ -22,19 +22,15 @@ 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."""
import_file_path: Optional[Union[str, Path]] = Field( load_import_file_path: Optional[Union[str, Path]] = Field(
default=None, default=None, description="Path to the file to import load data from."
description="Path to the file to import load data from.",
examples=[None, "/path/to/yearly_load.json"],
) )
import_json: Optional[str] = Field( load_import_json: Optional[str] = Field(
default=None, default=None, description="JSON string, dictionary of load forecast value lists."
description="JSON string, dictionary of load forecast value lists.",
examples=['{"load0_mean": [676.71, 876.19, 527.13]}'],
) )
# Validators # Validators
@field_validator("import_file_path", mode="after") @field_validator("load_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:
@@ -62,10 +58,7 @@ 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.provider_settings is None: if self.config.load_import_file_path is not None:
logger.debug(f"{self.provider_id()} data update without provider settings.") self.import_from_file(self.config.load_import_file_path, key_prefix="load")
return if self.config.load_import_json is not None:
if self.config.load.provider_settings.import_file_path: self.import_from_json(self.config.load_import_json, key_prefix="load")
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

@@ -28,7 +28,7 @@ Attributes:
from typing import List, Optional, Union from typing import List, Optional, Union
from pydantic import Field from pydantic import Field, computed_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
@@ -41,34 +41,65 @@ from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
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):
"""General Prediction Configuration. """Base configuration for prediction settings, including forecast duration, geographic location, and time zone.
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). parameters such as the forecast duration (in hours) and location (latitude and longitude).
Validators ensure each parameter is within a specified range. Validators ensure each parameter is within a specified range. A computed property, `timezone`,
determines the time zone based on latitude and longitude.
Attributes: Attributes:
hours (Optional[int]): Number of hours into the future for predictions. prediction_hours (Optional[int]): Number of hours into the future for predictions.
Must be non-negative. Must be non-negative.
historic_hours (Optional[int]): Number of hours into the past for historical data. prediction_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_hours (int): Ensures `hours` is a non-negative integer. validate_prediction_hours (int): Ensures `prediction_hours` is a non-negative integer.
validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer. validate_prediction_historic_hours (int): Ensures `prediction_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.
""" """
hours: Optional[int] = Field( prediction_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"
) )
historic_hours: Optional[int] = Field( prediction_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):

View File

@@ -114,16 +114,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 `hours`. """Compute the end datetime based on the `start_datetime` and `prediction_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 +147,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,6 +206,9 @@ 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,157 +1,397 @@
"""PV forecast module for PV power predictions.""" """PV forecast module for PV power predictions."""
from typing import Any, ClassVar, List, Optional, Self from typing import Any, ClassVar, List, Optional
from pydantic import Field, computed_field, field_validator, model_validator from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
from akkudoktoreos.utils.docs import get_model_structure_from_examples
logger = get_logger(__name__) logger = get_logger(__name__)
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=None,
description="Tilt angle from horizontal plane. Ignored for two-axis tracking.",
examples=[10.0, 20.0],
)
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).",
examples=[10.0, 20.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
provider: Optional[str] = Field( pvforecast_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="PVForecast provider id of provider to be used.", description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
examples=["PVForecastAkkudoktor"],
) )
pvforecast0_userhorizon: Optional[List[float]] = Field(
planes: Optional[list[PVForecastPlaneSetting]] = Field(
default=None, default=None,
description="Plane configuration.", description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
examples=[get_model_structure_from_examples(PVForecastPlaneSetting, True)], )
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."
) )
max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set pvforecast_max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set
@field_validator("planes") # Computed fields
def validate_planes(
cls, planes: Optional[list[PVForecastPlaneSetting]]
) -> Optional[list[PVForecastPlaneSetting]]:
if planes is not None and len(planes) > cls.max_planes:
raise ValueError(f"Maximum number of supported planes: {cls.max_planes}.")
return planes
provider_settings: Optional[PVForecastImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
)
## Computed fields
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def planes_peakpower(self) -> List[float]: def pvforecast_planes(self) -> List[str]:
"""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 = []
if self.planes: for plane in self.pvforecast_planes:
for plane in self.planes: peakpower_attr = f"{plane}_peakpower"
peakpower = plane.peakpower peakpower = getattr(self, peakpower_attr, None)
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))
@@ -162,13 +402,13 @@ class PVForecastCommonSettings(SettingsBaseModel):
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def planes_azimuth(self) -> List[float]: def pvforecast_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 = []
if self.planes: for plane in self.pvforecast_planes:
for plane in self.planes: azimuth_attr = f"{plane}_surface_azimuth"
azimuth = plane.surface_azimuth azimuth = getattr(self, azimuth_attr, None)
if azimuth is None: if azimuth is None:
# TODO Use default # TODO Use default
planes_azimuth.append(float(180)) planes_azimuth.append(float(180))
@@ -179,13 +419,13 @@ class PVForecastCommonSettings(SettingsBaseModel):
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def planes_tilt(self) -> List[float]: def pvforecast_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 = []
if self.planes: for plane in self.pvforecast_planes:
for plane in self.planes: tilt_attr = f"{plane}_surface_tilt"
tilt = plane.surface_tilt tilt = getattr(self, tilt_attr, None)
if tilt is None: if tilt is None:
# TODO Use default # TODO Use default
planes_tilt.append(float(30)) planes_tilt.append(float(30))
@@ -196,13 +436,13 @@ class PVForecastCommonSettings(SettingsBaseModel):
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def planes_userhorizon(self) -> Any: def pvforecast_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 = []
if self.planes: for plane in self.pvforecast_planes:
for plane in self.planes: userhorizon_attr = f"{plane}_userhorizon"
userhorizon = plane.userhorizon userhorizon = getattr(self, userhorizon_attr, None)
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)])
@@ -213,13 +453,13 @@ class PVForecastCommonSettings(SettingsBaseModel):
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def planes_inverter_paco(self) -> Any: def pvforecast_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 = []
if self.planes: for plane in self.pvforecast_planes:
for plane in self.planes: inverter_paco_attr = f"{plane}_inverter_paco"
inverter_paco = plane.inverter_paco inverter_paco = getattr(self, inverter_paco_attr, None)
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)

View File

@@ -28,18 +28,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:
provider (str): Prediction provider for pvforecast. pvforecast_provider (str): Prediction provider for pvforecast.
Attributes: Attributes:
hours (int, optional): The number of hours into the future for which predictions are generated. prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
historic_hours (int, optional): The number of past hours for which historical data is retained. prediction_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 `hours`. calculated based on `start_datetime` and `prediction_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 `historic_hours`. based on `start_datetime` and `prediction_historic_hours`.
""" """
# overload # overload
@@ -54,6 +54,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,33 +14,21 @@ 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 = {
"general": { "prediction_hours": 48,
"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": -10, "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
@@ -59,12 +47,12 @@ Example:
print(forecast.report_ac_power_and_measurement()) print(forecast.report_ac_power_and_measurement())
Attributes: Attributes:
hours (int): Number of hours into the future to forecast. Default is 48. prediction_hours (int): Number of hours into the future to forecast. Default is 48.
historic_hours (int): Number of past hours to retain for analysis. Default is 24. prediction_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 `hours`. end_datetime (datetime): Computed end datetime based on `start_datetime` and `prediction_hours`.
keep_datetime (datetime): Computed threshold datetime for retaining historical data. keep_datetime (datetime): Computed threshold datetime for retaining historical data.
Methods: Methods:
@@ -80,13 +68,13 @@ from typing import Any, List, Optional, Union
import requests import requests
from pydantic import Field, ValidationError, computed_field from pydantic import Field, ValidationError, computed_field
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
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__) logger = get_logger(__name__)
@@ -171,13 +159,13 @@ class PVForecastAkkudoktor(PVForecastProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
hours (int, optional): Number of hours in the future for the forecast. prediction_hours (int, optional): Number of hours in the future for the forecast.
historic_hours (int, optional): Number of past hours for retaining data. prediction_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 `hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -215,19 +203,19 @@ 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.general.latitude}", f"lat={self.config.latitude}",
f"lon={self.config.general.longitude}", f"lon={self.config.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])}") query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}")
query_params.append(f"tilt={int(self.config.pvforecast.planes_tilt[i])}") 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(int(h)) for h in self.config.pvforecast_planes_userhorizon[i]
) )
query_params.append(f"horizont={horizon_values}") query_params.append(f"horizont={horizon_values}")
@@ -238,7 +226,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.general.timezone}", f"timezone={self.config.timezone}",
"hourly=relativehumidity_2m%2Cwindspeed_10m", "hourly=relativehumidity_2m%2Cwindspeed_10m",
] ]
) )
@@ -267,7 +255,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
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:
@@ -277,7 +265,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}")
@@ -287,17 +275,17 @@ 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.general.timezone != akkudoktor_data.meta.timezone: if self.config.timezone != akkudoktor_data.meta.timezone:
error_msg = f"Configured timezone '{self.config.general.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'." error_msg = f"Configured timezone '{self.config.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}")
@@ -308,7 +296,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
# 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.general.timezone) dt = to_datetime(original_datetime, in_timezone=self.config.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:
@@ -326,9 +314,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."
) )
@@ -377,47 +365,31 @@ 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 = {
"general": { "prediction_hours": 48,
"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,
},
{
"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,
},
],
},
} }
# Initialize the forecast object with the generated configuration # Initialize the forecast object with the generated configuration

View File

@@ -22,22 +22,21 @@ 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."""
import_file_path: Optional[Union[str, Path]] = Field( pvforecastimport_file_path: Optional[Union[str, Path]] = Field(
default=None, default=None, description="Path to the file to import PV forecast data from."
description="Path to the file to import PV forecast data from.",
examples=[None, "/path/to/pvforecast.json"],
) )
import_json: Optional[str] = Field( pvforecastimport_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("import_file_path", mode="after") @field_validator("pvforecastimport_file_path", mode="after")
@classmethod @classmethod
def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]: def validate_pvforecastimport_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):
@@ -63,16 +62,7 @@ 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.pvforecast.provider_settings is None: if self.config.pvforecastimport_file_path is not None:
logger.debug(f"{self.provider_id()} data update without provider settings.") self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast")
return if self.config.pvforecastimport_json is not None:
if self.config.pvforecast.provider_settings.import_file_path is not None: self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast")
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

@@ -5,18 +5,9 @@ from typing import Optional
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
class WeatherCommonSettings(SettingsBaseModel): class WeatherCommonSettings(SettingsBaseModel):
"""Weather Forecast Configuration.""" weather_provider: Optional[str] = Field(
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]
) )

View File

@@ -101,18 +101,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:
provider (str): Prediction provider for weather. weather_provider (str): Prediction provider for weather.
Attributes: Attributes:
hours (int, optional): The number of hours into the future for which predictions are generated. prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
historic_hours (int, optional): The number of past hours for which historical data is retained. prediction_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 `hours`. calculated based on `start_datetime` and `prediction_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 `historic_hours`. based on `start_datetime` and `prediction_historic_hours`.
""" """
# overload # overload
@@ -126,7 +126,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

@@ -13,9 +13,9 @@ import pandas as pd
import pvlib import pvlib
import requests import requests
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
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 from akkudoktoreos.utils.datetimeutil import to_datetime
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -62,13 +62,13 @@ class WeatherBrightSky(WeatherProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
hours (int, optional): Number of hours in the future for the forecast. prediction_hours (int, optional): Number of hours in the future for the forecast.
historic_hours (int, optional): Number of past hours for retaining data. prediction_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 `hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -99,7 +99,7 @@ class WeatherBrightSky(WeatherProvider):
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD") date = to_datetime(self.start_datetime, 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")
response = requests.get( response = requests.get(
f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}" f"{source}/weather?lat={self.config.latitude}&lon={self.config.longitude}&date={date}&last_date={last_date}&tz={self.config.timezone}"
) )
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 +109,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.general.timezone) self.update_datetime = to_datetime(in_timezone=self.config.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:
@@ -200,7 +200,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.general.latitude, self.config.general.longitude, cloud_cover self.config.latitude, self.config.longitude, cloud_cover
) )
description = "Global Horizontal Irradiance (W/m2)" description = "Global Horizontal Irradiance (W/m2)"

View File

@@ -19,9 +19,9 @@ import pandas as pd
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
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__) logger = get_logger(__name__)
@@ -68,15 +68,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:
hours (int, optional): The number of hours into the future for which predictions are generated. prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
historic_hours (int, optional): The number of past hours for which historical data is retained. prediction_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 `hours`. calculated based on `start_datetime` and `prediction_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 `historic_hours`. based on `start_datetime` and `prediction_historic_hours`.
""" """
@classmethod @classmethod
@@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider):
response: Weather forecast request reponse from ClearOutside. response: Weather forecast request reponse from ClearOutside.
""" """
source = "https://clearoutside.com/forecast" source = "https://clearoutside.com/forecast"
latitude = round(self.config.general.latitude, 2) latitude = round(self.config.latitude, 2)
longitude = round(self.config.general.longitude, 2) longitude = round(self.config.longitude, 2)
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true") response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
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.general.timezone) self.update_datetime = to_datetime(in_timezone=self.config.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 +307,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.general.latitude, self.config.general.longitude, cloud_cover self.config.latitude, self.config.longitude, cloud_cover
) )
# Add GHI, DNI, DHI to clearout data # Add GHI, DNI, DHI to clearout data

View File

@@ -22,22 +22,18 @@ 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."""
import_file_path: Optional[Union[str, Path]] = Field( weatherimport_file_path: Optional[Union[str, Path]] = Field(
default=None, default=None, description="Path to the file to import weather data from."
description="Path to the file to import weather data from.",
examples=[None, "/path/to/weather_data.json"],
) )
import_json: Optional[str] = Field( weatherimport_json: Optional[str] = Field(
default=None, default=None, description="JSON string, dictionary of weather forecast value lists."
description="JSON string, dictionary of weather forecast value lists.",
examples=['{"weather_temp_air": [18.3, 17.8, 16.9]}'],
) )
# Validators # Validators
@field_validator("import_file_path", mode="after") @field_validator("weatherimport_file_path", mode="after")
@classmethod @classmethod
def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]: def validate_weatherimport_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):
@@ -63,14 +59,7 @@ 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.weather.provider_settings is None: if self.config.weatherimport_file_path is not None:
logger.debug(f"{self.provider_id()} data update without provider settings.") self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather")
return if self.config.weatherimport_json is not None:
if self.config.weather.provider_settings.import_file_path: self.import_from_json(self.config.weatherimport_json, key_prefix="weather")
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"
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1 +0,0 @@
{"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.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,38 +0,0 @@
# 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.6.3.min.js", crossorigin="anonymous"),
Script(
src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.6.3.min.js",
crossorigin="anonymous",
),
Script(
src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.6.3.min.js", crossorigin="anonymous"
),
Script(
src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.6.3.min.js", crossorigin="anonymous"
),
Script(
src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.6.3.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

@@ -1,224 +0,0 @@
from typing import Any, Optional, Union
from fasthtml.common import H1, Div, Li
# from mdit_py_plugins import plugin1, plugin2
from monsterui.foundations import stringify
from monsterui.franken import (
Button,
ButtonT,
Card,
Container,
ContainerT,
Details,
DivLAligned,
DivRAligned,
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 ConfigCard(
config_name: str, config_type: str, read_only: str, value: str, default: str, description: str
) -> Card:
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
),
DivRAligned(
P(read_only),
),
),
Input(value=value) if read_only == "rw" else P(value),
),
# cls="flex cursor-pointer list-none items-center gap-4",
cls="list-none",
),
Grid(
P(description),
P(config_type),
),
Grid(
DivRAligned(
P("default") if read_only == "rw" else P(""),
),
P(default) if read_only == "rw" else P(""),
)
if read_only == "rw"
else None,
cls="space-y-4 gap-4",
),
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(
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

@@ -1,275 +0,0 @@
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
import requests
from monsterui.franken import Div, DividerLine, P, Table, Tbody, Td, Th, Thead, Tr
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.server.dash.components import ConfigCard
logger = get_logger(__name__)
config_eos = get_config()
T = TypeVar("T")
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) and isinstance(key, str):
current = current[key]
elif isinstance(current, list) and isinstance(key, int):
current = current[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(values: dict) -> list[dict]:
"""Generate configuration details based on provided values and model metadata.
Args:
values (dict): A dictionary containing the current configuration 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(config_eos.model_fields.items()) + list(
config_eos.model_computed_fields.items()
):
def extract_nested_models(
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
) -> None:
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 = {}
config["name"] = ".".join(parent_types)
config["value"] = str(get_nested_value(values, parent_types, "<unknown>"))
config["default"] = str(get_default_value(subfield_info, regular_field))
config["description"] = (
subfield_info.description if subfield_info.description else ""
)
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("[", "[ ")
.replace("NoneType", "None")
)
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: Optional[str], eos_port: Optional[Union[str, int]]) -> list[dict]:
"""Fetch and process configuration data from the specified EOS server.
Args:
eos_host (Optional[str]): The hostname of the server.
eos_port (Optional[Union[str, int]]): The port of the server.
Returns:
List[dict]: A list of processed configuration entries.
"""
if eos_host is None:
eos_host = config_eos.server.host
if eos_port is None:
eos_port = config_eos.server.port
server = f"http://{eos_host}:{eos_port}"
# Get current configuration from server
try:
result = requests.get(f"{server}/v1/config")
result.raise_for_status()
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
logger.warning(warning_msg)
return configuration({})
config = result.json()
return configuration(config)
def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Div:
"""Create a visual representation of the configuration.
Args:
eos_host (Optional[str]): The hostname of the EOS server.
eos_port (Optional[Union[str, int]]): The port of the EOS server.
Returns:
Table: A `monsterui.franken.Table` component displaying configuration details.
"""
flds = "Name", "Type", "RO/RW", "Value", "Default", "Description"
rows = []
last_category = ""
for config in get_configuration(eos_host, eos_port):
category = config["name"].split(".")[0]
if category != last_category:
rows.append(P(category))
rows.append(DividerLine())
last_category = category
rows.append(
ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
)
)
return Div(*rows, cls="space-y-4")
def ConfigurationOrg(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table:
"""Create a visual representation of the configuration.
Args:
eos_host (Optional[str]): The hostname of the EOS server.
eos_port (Optional[Union[str, int]]): The port of the EOS server.
Returns:
Table: A `monsterui.franken.Table` component displaying configuration details.
"""
flds = "Name", "Type", "RO/RW", "Value", "Default", "Description"
rows = [
Tr(
Td(
config["name"],
cls="max-w-64 text-wrap break-all",
),
Td(
config["type"],
cls="max-w-48 text-wrap break-all",
),
Td(
config["read-only"],
cls="max-w-24 text-wrap break-all",
),
Td(
config["value"],
cls="max-w-md text-wrap break-all",
),
Td(config["default"], cls="max-w-48 text-wrap break-all"),
Td(
config["description"],
cls="max-w-prose text-wrap",
),
cls="",
)
for config in get_configuration(eos_host, eos_port)
]
head = Thead(*map(Th, flds), cls="text-left")
return Table(head, Tbody(*rows), cls="w-full uk-table uk-table-divider uk-table-striped")

View File

@@ -1,86 +0,0 @@
{
"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": -10,
"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": -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
}
],
"provider": "PVForecastAkkudoktor"
},
"server": {
"startup_eosdash": true,
"host": "0.0.0.0",
"port": 8503,
"eosdash_host": "0.0.0.0",
"eosdash_port": 8504
},
"weather": {
"provider": "BrightSky"
}
}

View File

@@ -1,217 +0,0 @@
import json
from pathlib import Path
from typing import Union
import pandas as pd
import requests
from bokeh.models import ColumnDataSource, Range1d
from bokeh.plotting import figure
from monsterui.franken import FT, Grid, P
from akkudoktoreos.core.logging import get_logger
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}")
logger = get_logger(__name__)
# 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 DemoWeatherTempAir(predictions: pd.DataFrame, config: dict) -> FT:
source = ColumnDataSource(predictions)
provider = config["weather"]["provider"]
plot = figure(
x_axis_type="datetime",
y_range=Range1d(
predictions["weather_temp_air"].min() - 1.0, predictions["weather_temp_air"].max() + 1.0
),
title=f"Air Temperature Prediction ({provider})",
x_axis_label="Datetime",
y_axis_label="Temperature [°C]",
sizing_mode="stretch_width",
height=400,
)
plot.line(
"date_time", "weather_temp_air", source=source, legend_label="Air Temperature", color="blue"
)
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 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")
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)
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)
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")
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)
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_temp_air",
"weather_ghi",
"weather_dni",
"weather_dhi",
],
}
result = requests.get(f"{server}/v1/prediction/dataframe", params=params)
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)
return Grid(
DemoPVForecast(predictions, democonfig),
DemoElectricityPriceForecast(predictions, democonfig),
DemoWeatherTempAir(predictions, democonfig),
DemoWeatherIrradiance(predictions, democonfig),
cols_max=2,
)

View File

@@ -1,92 +0,0 @@
from typing import Optional, Union
import requests
from monsterui.daisy import Loading, LoadingT
from monsterui.franken import A, ButtonT, DivFullySpaced, P
from requests.exceptions import RequestException
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
config_eos = get_config()
def get_alive(eos_host: str, eos_port: Union[str, int]) -> str:
"""Fetch alive information from the specified EOS server.
Args:
eos_host (str): The hostname of the server.
eos_port (Union[str, int]): The port of the server.
Returns:
str: Alive data.
"""
result = requests.Response()
try:
result = requests.get(f"http://{eos_host}:{eos_port}/v1/health")
if result.status_code == 200:
alive = result.json()["status"]
else:
alive = f"Server responded with status code: {result.status_code}"
except RequestException as e:
warning_msg = f"{e}"
logger.warning(warning_msg)
alive = warning_msg
return alive
def Footer(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> str:
if eos_host is None:
eos_host = config_eos.server.host
if eos_port is None:
eos_port = config_eos.server.port
alive_icon = None
if eos_host is None or eos_port is None:
alive = "EOS server not given: {eos_host}:{eos_port}"
else:
alive = get_alive(eos_host, eos_port)
if alive == "alive":
alive_icon = Loading(
cls=(
LoadingT.ring,
LoadingT.sm,
),
)
alive = f"EOS {eos_host}:{eos_port}"
if alive_icon:
alive_cls = f"{ButtonT.primary} uk-link rounded-md"
else:
alive_cls = f"{ButtonT.secondary} uk-link rounded-md"
return DivFullySpaced(
P(
alive_icon,
A(alive, href=f"http://{eos_host}:{eos_port}/docs", target="_blank", cls=alive_cls),
),
P(
A(
"Documentation",
href="https://akkudoktor-eos.readthedocs.io/en/latest/",
target="_blank",
cls="uk-link",
),
),
P(
A(
"Issues",
href="https://github.com/Akkudoktor-EOS/EOS/issues",
target="_blank",
cls="uk-link",
),
),
P(
A(
"GitHub",
href="https://github.com/Akkudoktor-EOS/EOS/",
target="_blank",
cls="uk-link",
),
),
cls="uk-padding-remove-top uk-padding-remove-botton",
)

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