├── .dockerignore
├── .env
├── .github
├── ISSUE_TEMPLATE
│ ├── bug-report.yml
│ ├── developer.yml
│ └── enhancement.yml
├── dependabot.yml
└── workflows
│ ├── docker-build.yml
│ ├── pre-commit.yml
│ ├── pytest.yml
│ └── stale.yml
├── .gitignore
├── .gitlint
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── .vscode
├── extensions.json
└── settings.shared.json
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── Makefile
├── NOTICE
├── README.md
├── docker-compose.yaml
├── docs
├── _generated
│ ├── config.md
│ └── openapi.md
├── _static
│ ├── architecture-overall.odp
│ ├── architecture-overall.png
│ ├── architecture-system.odp
│ ├── architecture-system.png
│ ├── eos.css
│ ├── introduction
│ │ ├── integration.png
│ │ ├── introduction.png
│ │ └── overview.png
│ ├── logo.png
│ ├── optimization_timeframes-excalidraw.json
│ └── optimization_timeframes.png
├── _templates
│ └── autosummary
│ │ ├── class.rst
│ │ └── module.rst
├── akkudoktoreos
│ ├── api.rst
│ ├── architecture.md
│ ├── configuration.md
│ ├── integration.md
│ ├── introduction.md
│ ├── measurement.md
│ ├── optimization.md
│ ├── prediction.md
│ └── serverapi.md
├── conf.py
├── develop
│ ├── CONTRIBUTING.md
│ └── getting_started.md
├── index.md
├── pymarkdown.json
└── welcome.md
├── openapi.json
├── pyproject.toml
├── requirements-dev.txt
├── requirements.txt
├── scripts
├── __init__.py
├── extract_markdown.py
├── generate_config_md.py
├── generate_openapi.py
├── generate_openapi_md.py
└── gitlint
│ └── eos_commit_rules.py
├── single_test_optimization.py
├── single_test_prediction.py
├── src
└── akkudoktoreos
│ ├── __init__.py
│ ├── config
│ ├── __init__.py
│ ├── config.py
│ └── configabc.py
│ ├── core
│ ├── __init__.py
│ ├── cache.py
│ ├── cachesettings.py
│ ├── coreabc.py
│ ├── dataabc.py
│ ├── decorators.py
│ ├── ems.py
│ ├── emsettings.py
│ ├── logabc.py
│ ├── logging.py
│ ├── logsettings.py
│ └── pydantic.py
│ ├── data
│ ├── default.config.json
│ ├── load_profiles.npz
│ └── regular_grid_interpolator.pkl
│ ├── devices
│ ├── __init__.py
│ ├── battery.py
│ ├── devices.py
│ ├── devicesabc.py
│ ├── generic.py
│ ├── heatpump.py
│ ├── inverter.py
│ └── settings.py
│ ├── measurement
│ ├── __init__.py
│ └── measurement.py
│ ├── optimization
│ ├── __init__.py
│ ├── genetic.py
│ ├── optimization.py
│ └── optimizationabc.py
│ ├── prediction
│ ├── __init__.py
│ ├── elecprice.py
│ ├── elecpriceabc.py
│ ├── elecpriceakkudoktor.py
│ ├── elecpriceimport.py
│ ├── interpolator.py
│ ├── load.py
│ ├── loadabc.py
│ ├── loadakkudoktor.py
│ ├── loadimport.py
│ ├── prediction.py
│ ├── predictionabc.py
│ ├── pvforecast.py
│ ├── pvforecastabc.py
│ ├── pvforecastakkudoktor.py
│ ├── pvforecastimport.py
│ ├── weather.py
│ ├── weatherabc.py
│ ├── weatherbrightsky.py
│ ├── weatherclearoutside.py
│ └── weatherimport.py
│ ├── server
│ ├── __init__.py
│ ├── dash
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── assets
│ │ │ ├── favicon
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ └── site.webmanifest
│ │ │ ├── icon.png
│ │ │ └── logo.png
│ │ ├── bokeh.py
│ │ ├── components.py
│ │ ├── configuration.py
│ │ ├── data
│ │ │ └── democonfig.json
│ │ ├── demo.py
│ │ ├── footer.py
│ │ ├── hello.py
│ │ └── markdown.py
│ ├── eos.py
│ ├── eosdash.py
│ ├── rest
│ │ ├── __init__.py
│ │ ├── error.py
│ │ └── tasks.py
│ └── server.py
│ └── utils
│ ├── __init__.py
│ ├── datetimeutil.py
│ ├── docs.py
│ ├── utils.py
│ └── visualize.py
└── tests
├── conftest.py
├── test_battery.py
├── test_cache.py
├── test_class_ems.py
├── test_class_ems_2.py
├── test_class_optimize.py
├── test_config.py
├── test_configabc.py
├── test_dataabc.py
├── test_datetimeutil.py
├── test_doc.py
├── test_elecpriceakkudoktor.py
├── test_elecpriceimport.py
├── test_eosdashconfig.py
├── test_eosdashserver.py
├── test_heatpump.py
├── test_inverter.py
├── test_loadakkudoktor.py
├── test_logging.py
├── test_measurement.py
├── test_prediction.py
├── test_predictionabc.py
├── test_pvforecast.py
├── test_pvforecastakkudoktor.py
├── test_pvforecastimport.py
├── test_pydantic.py
├── test_server.py
├── test_visualize.py
├── test_weatherbrightsky.py
├── test_weatherclearoutside.py
├── test_weatherimport.py
└── testdata
├── elecpriceforecast_akkudoktor_1.json
├── eosserver_config_1.json
├── import_input_1.json
├── optimize_input_1.json
├── optimize_input_2.json
├── optimize_result_1.json
├── optimize_result_2.json
├── optimize_result_2_full.json
├── pv_forecast_input_1.json
├── pv_forecast_input_single_plane.json
├── pv_forecast_result_1.txt
├── test_example_report.pdf
├── weatherforecast_brightsky_1.json
├── weatherforecast_brightsky_2.json
├── weatherforecast_clearout_1.html
└── weatherforecast_clearout_1.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git/
2 | .github/
3 | **/__pycache__/
4 | **/*.pyc
5 | **/*.egg-info/
6 | .dockerignore
7 | .env
8 | .gitignore
9 | docker-compose.yaml
10 | Dockerfile
11 | LICENSE
12 | Makefile
13 | NOTICE
14 | README.md
15 | .venv/
16 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | EOS_VERSION=main
2 | EOS_SERVER__PORT=8503
3 | EOS_SERVER__EOSDASH_PORT=8504
4 |
5 | PYTHON_VERSION=3.12.6
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report.
3 | title: "[BUG]: ..."
4 | labels: ["bug"]
5 |
6 | body:
7 | - type: markdown
8 | attributes:
9 | value: >
10 | Thank you for taking the time to file a bug report.
11 | Please also check the issue tracker for existing issues about the bug.
12 |
13 | - type: textarea
14 | attributes:
15 | label: "Describe the issue:"
16 | validations:
17 | required: true
18 |
19 | - type: textarea
20 | attributes:
21 | label: "Reproduceable code example:"
22 | description: >
23 | A short code example that reproduces the problem/missing feature.
24 | placeholder: |
25 | << your code here >>
26 | render: python
27 | validations:
28 | required: false
29 |
30 | - type: textarea
31 | attributes:
32 | label: "Error message:"
33 | description: >
34 | Please include full error message, if any.
35 | placeholder: |
36 |
37 | Full traceback starting from `Traceback: ...`
38 |
39 | render: shell
40 |
41 | - type: textarea
42 | attributes:
43 | label: "Version information:"
44 | description: >
45 | EOS Version or commit SHA:
46 | Operating system:
47 | How did you install EOS?
48 | placeholder: |
49 |
50 | configuration information
51 |
52 | validations:
53 | required: true
54 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/developer.yml:
--------------------------------------------------------------------------------
1 | name: Developer issue
2 | description: This template is for developers/maintainers only!
3 |
4 | body:
5 | - type: textarea
6 | attributes:
7 | label: Description
8 | validations:
9 | required: true
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/enhancement.yml:
--------------------------------------------------------------------------------
1 | name: Enhancement
2 | description: Make a specific, well-motivated proposal for a feature.
3 | title: "[ENH]: ..."
4 | labels: [enhancement]
5 |
6 |
7 | body:
8 | - type: markdown
9 | attributes:
10 | value: >
11 | Please post your idea first as a [Discussion](https://github.com/Akkudoktor-EOS/EOS/discussions)
12 | to validate it and bring attention to it. After validation,
13 | you can open this issue for a more technical developer discussion.
14 | Check the [Contributor Guide](https://github.com/Akkudoktor-EOS/EOS/blob/main/CONTRIBUTING.md)
15 | if you need more information.
16 |
17 | - type: textarea
18 | attributes:
19 | label: "Link to discussion and related issues"
20 | description: >
21 |
22 | render: python
23 | validations:
24 | required: false
25 |
26 | - type: textarea
27 | attributes:
28 | label: "Proposed implementation"
29 | description: >
30 | How it could be implemented with a high level API.
31 | render: python
32 | validations:
33 | required: false
34 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "pip" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.github/workflows/pre-commit.yml:
--------------------------------------------------------------------------------
1 | name: pre-commit
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [main]
7 |
8 | jobs:
9 | pre-commit:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-python@v5
14 | - uses: pre-commit/action@v3.0.1
15 |
--------------------------------------------------------------------------------
/.github/workflows/pytest.yml:
--------------------------------------------------------------------------------
1 | name: Run Pytest on Pull Request
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [main]
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v2
15 |
16 | - name: Set up Python
17 | uses: actions/setup-python@v5
18 | with:
19 | python-version: "3.12"
20 |
21 | - name: Install dependencies
22 | run: |
23 | python -m pip install --upgrade pip
24 | pip install -r requirements-dev.txt
25 |
26 | - name: Run Pytest
27 | run: |
28 | pip install -e .
29 | python -m pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing
30 |
31 | - name: Upload test artifacts
32 | uses: actions/upload-artifact@v4
33 | with:
34 | name: optimize-results
35 | path: tests/testdata/new_optimize_result*
36 |
--------------------------------------------------------------------------------
/.github/workflows/stale.yml:
--------------------------------------------------------------------------------
1 | name: "Close stale pull requests/issues"
2 | on:
3 | schedule:
4 | - cron: "16 00 * * *"
5 |
6 | permissions:
7 | contents: read
8 |
9 | jobs:
10 | stale:
11 | name: Find Stale issues and PRs
12 | runs-on: ubuntu-22.04
13 | if: github.repository == 'Akkudoktor-EOS/EOS'
14 | permissions:
15 | pull-requests: write # to comment on stale pull requests
16 | issues: write # to comment on stale issues
17 |
18 | steps:
19 | - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
20 | with:
21 | stale-pr-message: 'This pull request has been marked as stale because it has been open (more
22 | than) 90 days with no activity. Remove the stale label or add a comment saying that you
23 | would like to have the label removed otherwise this pull request will automatically be
24 | closed in 30 days. Note, that you can always re-open a closed pull request at any time.'
25 | stale-issue-message: 'This issue has been marked as stale because it has been open (more
26 | than) 90 days with no activity. Remove the stale label or add a comment saying that you
27 | would like to have the label removed otherwise this issue will automatically be closed in
28 | 30 days. Note, that you can always re-open a closed issue at any time.'
29 | days-before-stale: 90
30 | days-before-close: 30
31 | stale-issue-label: 'stale'
32 | stale-pr-label: 'stale'
33 | exempt-pr-labels: 'in progress'
34 | exempt-issue-labels: 'feature request, enhancement'
35 | operations-per-run: 400
36 |
--------------------------------------------------------------------------------
/.gitlint:
--------------------------------------------------------------------------------
1 | [general]
2 | # verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
3 | verbosity = 3
4 |
5 | regex-style-search=true
6 |
7 | # Ignore rules, reference them by id or name (comma-separated)
8 | ignore=title-trailing-punctuation, T3
9 |
10 | # Enable specific community contributed rules
11 | contrib=contrib-title-conventional-commits,CC1
12 |
13 | # Set the extra-path where gitlint will search for user defined rules
14 | extra-path=scripts/gitlint
15 |
16 | [title-max-length]
17 | line-length=80
18 |
19 | [title-min-length]
20 | min-length=5
21 |
22 | [ignore-by-title]
23 | # Match commit titles starting with "Release"
24 | regex=^Release(.*)
25 | ignore=title-max-length,body-min-length
26 |
27 | [ignore-by-body]
28 | # Match commits message bodies that have a line that contains 'release'
29 | regex=(.*)release(.*)
30 | ignore=all
31 |
32 | [ignore-by-author-name]
33 | # Match commits by author name (e.g. ignore dependabot commits)
34 | regex=dependabot
35 | ignore=all
36 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # Exclude some file types from automatic code style
2 | exclude: \.(json|csv)$
3 | repos:
4 | - repo: https://github.com/pre-commit/pre-commit-hooks
5 | rev: v5.0.0
6 | hooks:
7 | - id: check-merge-conflict
8 | - id: check-toml
9 | - id: check-yaml
10 | - id: end-of-file-fixer
11 | - id: trailing-whitespace
12 | - id: check-merge-conflict
13 | exclude: '\.rst$' # Exclude .rst files
14 | - repo: https://github.com/PyCQA/isort
15 | rev: 6.0.0
16 | hooks:
17 | - id: isort
18 | name: isort
19 | - repo: https://github.com/astral-sh/ruff-pre-commit
20 | rev: v0.9.6
21 | hooks:
22 | # Run the linter and fix simple issues automatically
23 | - id: ruff
24 | args: [--fix]
25 | # Run the formatter.
26 | - id: ruff-format
27 | - repo: https://github.com/pre-commit/mirrors-mypy
28 | rev: 'v1.15.0'
29 | hooks:
30 | - id: mypy
31 | additional_dependencies:
32 | - "types-requests==2.32.0.20241016"
33 | - "pandas-stubs==2.2.3.241009"
34 | - "numpy==2.1.3"
35 | pass_filenames: false
36 | - repo: https://github.com/jackdewinter/pymarkdown
37 | rev: v0.9.29
38 | hooks:
39 | - id: pymarkdown
40 | files: ^docs/
41 | exclude: ^docs/_generated
42 | args:
43 | - --config=docs/pymarkdown.json
44 | - scan
45 | - repo: https://github.com/jorisroovers/gitlint
46 | rev: v0.19.1
47 | hooks:
48 | - id: gitlint
49 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: ubuntu-24.04
5 | tools:
6 | python: "3.12"
7 |
8 | sphinx:
9 | configuration: docs/conf.py
10 |
11 | python:
12 | install:
13 | - requirements: requirements-dev.txt
14 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | //python
4 | "ms-python.python",
5 | "ms-python.debugpy",
6 | "charliermarsh.ruff",
7 | "ms-python.mypy-type-checker",
8 |
9 | // misc
10 | "swellaby.workspace-config-plus", // allows user and shared settings
11 | "christian-kohler.path-intellisense",
12 | "esbenp.prettier-vscode"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/.vscode/settings.shared.json:
--------------------------------------------------------------------------------
1 | {
2 | "[python]": {
3 | "editor.defaultFormatter": "charliermarsh.ruff"
4 | },
5 | "editor.defaultFormatter": "esbenp.prettier-vscode",
6 | "editor.defaultFoldingRangeProvider": "charliermarsh.ruff",
7 | "editor.formatOnSave": true,
8 | "python.analysis.autoImportCompletions": true,
9 | "mypy-type-checker.importStrategy": "fromEnvironment",
10 | "python.testing.pytestArgs": [],
11 | "python.testing.unittestEnabled": false,
12 | "python.testing.pytestEnabled": true
13 | }
14 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to EOS
2 |
3 | Thanks for taking the time to read this!
4 |
5 | The `EOS` project is in early development, therefore we encourage contribution in the following ways:
6 |
7 | ## Documentation
8 |
9 | Latest development documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/).
10 |
11 | ## Bug Reports
12 |
13 | Please report flaws or vulnerabilities in the [GitHub Issue Tracker](https://github.com/Akkudoktor-EOS/EOS/issues) using the corresponding issue template.
14 |
15 | ## Ideas & Features
16 |
17 | Please first discuss the idea in a [GitHub Discussion](https://github.com/Akkudoktor-EOS/EOS/discussions) or the [Akkudoktor Forum](https://www.akkudoktor.net/forum/diy-energie-optimierungssystem-opensource-projekt/) before opening an issue.
18 |
19 | There are just too many possibilities and the project would drown in tickets otherwise.
20 |
21 | ## Code Contributions
22 |
23 | We welcome code contributions and bug fixes via [Pull Requests](https://github.com/Akkudoktor-EOS/EOS/pulls).
24 | To make collaboration easier, we require pull requests to pass code style, unit tests, and commit
25 | message style checks.
26 |
27 | ### Setup development environment
28 |
29 | Setup virtual environment, then activate virtual environment and install development dependencies.
30 | See also [README.md](README.md).
31 |
32 | ```bash
33 | python -m venv .venv
34 | source .venv/bin/activate
35 | pip install -r requirements-dev.txt
36 | pip install -e .
37 | ```
38 |
39 | Install make to get access to helpful shortcuts (documentation generation, manual formatting, etc.).
40 |
41 | - On Linux (Ubuntu/Debian):
42 |
43 | ```bash
44 | sudo apt install make
45 | ```
46 |
47 | - On MacOS (requires [Homebrew](https://brew.sh)):
48 |
49 | ```zsh
50 | brew install make
51 | ```
52 |
53 | The server can be started with `make run`. A full overview of the main shortcuts is given by `make help`.
54 |
55 | ### Code Style
56 |
57 | Our code style checks use [`pre-commit`](https://pre-commit.com).
58 |
59 | To run formatting automatically before every commit:
60 |
61 | ```bash
62 | pre-commit install
63 | pre-commit install --hook-type commit-msg
64 | ```
65 |
66 | Or run them manually:
67 |
68 | ```bash
69 | pre-commit run --all-files
70 | ```
71 |
72 | ### Tests
73 |
74 | Use `pytest` to run tests locally:
75 |
76 | ```bash
77 | python -m pytest -vs --cov src --cov-report term-missing tests/
78 | ```
79 |
80 | ### Commit message style
81 |
82 | Our commit message checks use [`gitlint`](https://github.com/jorisroovers/gitlint). The checks
83 | enforce the [`Conventional Commits`](https://www.conventionalcommits.org) commit message style.
84 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1.7
2 | ARG PYTHON_VERSION=3.12.7
3 | FROM python:${PYTHON_VERSION}-slim
4 |
5 | LABEL source="https://github.com/Akkudoktor-EOS/EOS"
6 |
7 | ENV MPLCONFIGDIR="/tmp/mplconfigdir"
8 | ENV EOS_DIR="/opt/eos"
9 | ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
10 | ENV EOS_OUTPUT_DIR="${EOS_DIR}/output"
11 | ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
12 |
13 | # Overwrite when starting the container in a production environment
14 | ENV EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
15 |
16 | WORKDIR ${EOS_DIR}
17 |
18 | RUN adduser --system --group --no-create-home eos \
19 | && mkdir -p "${MPLCONFIGDIR}" \
20 | && chown eos "${MPLCONFIGDIR}" \
21 | && mkdir -p "${EOS_CACHE_DIR}" \
22 | && chown eos "${EOS_CACHE_DIR}" \
23 | && mkdir -p "${EOS_OUTPUT_DIR}" \
24 | && chown eos "${EOS_OUTPUT_DIR}" \
25 | && mkdir -p "${EOS_CONFIG_DIR}" \
26 | && chown eos "${EOS_CONFIG_DIR}"
27 |
28 | COPY requirements.txt .
29 |
30 | RUN --mount=type=cache,target=/root/.cache/pip \
31 | pip install -r requirements.txt
32 |
33 | COPY pyproject.toml .
34 | RUN mkdir -p src && pip install -e .
35 |
36 | COPY src src
37 |
38 | USER eos
39 | ENTRYPOINT []
40 |
41 | EXPOSE 8503
42 | EXPOSE 8504
43 |
44 | CMD ["python", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"]
45 |
46 | VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"]
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 Dr. Andreas Schmitz, c/o Grosch Postflex #1662, Emsdettener Str. 10, 48268 Greven
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Define the targets
2 | .PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format gitlint mypy run run-dev
3 |
4 | # Default target
5 | all: help
6 |
7 | # Target to display help information
8 | help:
9 | @echo "Available targets:"
10 | @echo " venv - Set up a Python 3 virtual environment."
11 | @echo " pip - Install dependencies from requirements.txt."
12 | @echo " pip-dev - Install dependencies from requirements-dev.txt."
13 | @echo " format - Format source code."
14 | @echo " gitlint - Lint last commit message."
15 | @echo " mypy - Run mypy."
16 | @echo " install - Install EOS in editable form (development mode) into virtual environment."
17 | @echo " docker-run - Run entire setup on docker"
18 | @echo " docker-build - Rebuild docker image"
19 | @echo " docs - Generate HTML documentation (in build/docs/html/)."
20 | @echo " read-docs - Read HTML documentation in your browser."
21 | @echo " gen-docs - Generate openapi.json and docs/_generated/*."
22 | @echo " clean-docs - Remove generated documentation."
23 | @echo " run - Run EOS production server in virtual environment."
24 | @echo " run-dev - Run EOS development server in virtual environment (automatically reloads)."
25 | @echo " run-dash - Run EOSdash production server in virtual environment."
26 | @echo " run-dash-dev - Run EOSdash development server in virtual environment (automatically reloads)."
27 | @echo " test - Run tests."
28 | @echo " test-full - Run tests with full optimization."
29 | @echo " test-ci - Run tests as CI does. No user config file allowed."
30 | @echo " dist - Create distribution (in dist/)."
31 | @echo " clean - Remove generated documentation, distribution and virtual environment."
32 |
33 | # Target to set up a Python 3 virtual environment
34 | venv:
35 | python3 -m venv .venv
36 | @echo "Virtual environment created in '.venv'. Activate it using 'source .venv/bin/activate'."
37 |
38 | # Target to install dependencies from requirements.txt
39 | pip: venv
40 | .venv/bin/pip install --upgrade pip
41 | .venv/bin/pip install -r requirements.txt
42 | @echo "Dependencies installed from requirements.txt."
43 |
44 | # Target to install dependencies from requirements.txt
45 | pip-dev: pip
46 | .venv/bin/pip install -r requirements-dev.txt
47 | @echo "Dependencies installed from requirements-dev.txt."
48 |
49 | # Target to install EOS in editable form (development mode) into virtual environment.
50 | install: pip
51 | .venv/bin/pip install build
52 | .venv/bin/pip install -e .
53 | @echo "EOS installed in editable form (development mode)."
54 |
55 | # Target to create a distribution.
56 | dist: pip
57 | .venv/bin/pip install build
58 | .venv/bin/python -m build --wheel
59 | @echo "Distribution created (see dist/)."
60 |
61 | # Target to generate documentation
62 | gen-docs: pip-dev
63 | .venv/bin/pip install -e .
64 | .venv/bin/python ./scripts/generate_config_md.py --output-file docs/_generated/config.md
65 | .venv/bin/python ./scripts/generate_openapi_md.py --output-file docs/_generated/openapi.md
66 | .venv/bin/python ./scripts/generate_openapi.py --output-file openapi.json
67 | @echo "Documentation generated to openapi.json and docs/_generated."
68 |
69 | # Target to build HTML documentation
70 | docs: pip-dev
71 | .venv/bin/sphinx-build -M html docs build/docs
72 | @echo "Documentation build to build/docs/html/."
73 |
74 | # Target to read the HTML documentation
75 | read-docs: docs
76 | @echo "Read the documentation in your browser"
77 | .venv/bin/python -m webbrowser build/docs/html/index.html
78 |
79 | # Clean target to remove generated documentation and documentation artefacts
80 | clean-docs:
81 | @echo "Searching and deleting all '_autosum' directories in docs..."
82 | @find docs -type d -name '_autosummary' -exec rm -rf {} +;
83 | @echo "Cleaning docs build directories"
84 | rm -rf build/docs
85 |
86 | # Clean target to remove generated documentation, distribution and virtual environment
87 | clean: clean-docs
88 | @echo "Cleaning virtual env, distribution and build directories"
89 | rm -rf build .venv
90 | @echo "Deletion complete."
91 |
92 | run:
93 | @echo "Starting EOS production server, please wait..."
94 | .venv/bin/python -m akkudoktoreos.server.eos
95 |
96 | run-dev:
97 | @echo "Starting EOS development server, please wait..."
98 | .venv/bin/python -m akkudoktoreos.server.eos --host localhost --port 8503 --reload true
99 |
100 | run-dash:
101 | @echo "Starting EOSdash production server, please wait..."
102 | .venv/bin/python -m akkudoktoreos.server.eosdash
103 |
104 | run-dash-dev:
105 | @echo "Starting EOSdash development server, please wait..."
106 | .venv/bin/python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --reload true
107 |
108 | # Target to setup tests.
109 | test-setup: pip-dev
110 | @echo "Setup tests"
111 |
112 | # Target to run tests.
113 | test:
114 | @echo "Running tests..."
115 | .venv/bin/pytest -vs --cov src --cov-report term-missing
116 |
117 | # Target to run tests as done by CI on Github.
118 | test-ci:
119 | @echo "Running tests as CI..."
120 | .venv/bin/pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing
121 |
122 | # Target to run all tests.
123 | test-full:
124 | @echo "Running all tests..."
125 | .venv/bin/pytest --full-run
126 |
127 | # Target to format code.
128 | format:
129 | .venv/bin/pre-commit run --all-files
130 |
131 | # Target to trigger gitlint using pre-commit for the last commit message
132 | gitlint:
133 | .venv/bin/pre-commit run gitlint --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
134 |
135 | # Target to format code.
136 | mypy:
137 | .venv/bin/mypy
138 |
139 | # Run entire setup on docker
140 | docker-run:
141 | @docker compose up --remove-orphans
142 |
143 | docker-build:
144 | @docker compose build --pull
145 |
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | Energie Optimierungs System
2 |
3 | Copyright (c) 2024 Dr. Andreas Schmitz, c/o Grosch Postflex #1662, Emsdettener Str. 10, 48268 Greven
4 |
5 | This product includes software developed under the Apache License, Version 2.0.
6 |
7 | The source code of this product is publicly available and is distributed under the Apache License, Version 2.0, which allows users to use, modify, and redistribute it under the terms of the License.
8 |
9 | COPYRIGHT NOTICES AND LICENSING TERMS:
10 | Please see the LICENSE and README files for information on copyright and licensing.
11 |
12 | THIRD-PARTY COMPONENTS:
13 | This product may include software components that are subject to other open source licenses. Details on these components and their licenses can be found in the respective subdirectories.
14 |
15 | PATENT NOTICE:
16 | This product may utilize technologies covered under international patents and/or pending patent applications.
17 |
18 | ADDITIONAL ATTRIBUTIONS:
19 | The following is a list of licensors and other acknowledgements for third-party software that may be contained within this system:
20 | - FastAPI, licensed under the MIT License, see https://fastapi.tiangolo.com/
21 | - NumPy, licensed under the BSD License, see https://numpy.org/
22 | - Requests, licensed under the Apache License 2.0, see https://requests.readthedocs.io/
23 | - matplotlib, licensed under the matplotlib License (a variant of the Python Software Foundation License), see https://matplotlib.org/
24 | - DEAP, licensed under the GNU Lesser General Public License v3.0, see https://deap.readthedocs.io/
25 | - SciPy, licensed under the BSD License, see https://scipy.org/
26 | - scikit-learn (sklearn), licensed under the BSD License, see https://scikit-learn.org/
27 | - pandas, licensed under the BSD License, see https://pandas.pydata.org/
28 |
29 |
30 | DISCLAIMER:
31 | This product is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software.
32 |
33 | For further information, please contact info@akkudoktor.net
34 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | networks:
3 | default:
4 | name: "eos"
5 | services:
6 | eos:
7 | image: "akkudoktor/eos:${EOS_VERSION}"
8 | read_only: true
9 | build:
10 | context: .
11 | dockerfile: "Dockerfile"
12 | args:
13 | PYTHON_VERSION: "${PYTHON_VERSION}"
14 | env_file:
15 | - .env
16 | environment:
17 | - EOS_CONFIG_DIR=config
18 | - EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
19 | - EOS_PREDICTION__LATITUDE=52.2
20 | - EOS_PREDICTION__LONGITUDE=13.4
21 | - EOS_ELECPRICE__PROVIDER=ElecPriceAkkudoktor
22 | - EOS_ELECPRICE__CHARGES_KWH=0.21
23 | ports:
24 | # Configure what ports to expose on host
25 | - "${EOS_SERVER__PORT}:8503"
26 | - "${EOS_SERVER__EOSDASH_PORT}:8504"
27 |
--------------------------------------------------------------------------------
/docs/_static/architecture-overall.odp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/architecture-overall.odp
--------------------------------------------------------------------------------
/docs/_static/architecture-overall.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/architecture-overall.png
--------------------------------------------------------------------------------
/docs/_static/architecture-system.odp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/architecture-system.odp
--------------------------------------------------------------------------------
/docs/_static/architecture-system.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/architecture-system.png
--------------------------------------------------------------------------------
/docs/_static/eos.css:
--------------------------------------------------------------------------------
1 | .wy-nav-content {
2 | max-width: 90% !important;
3 | }
4 |
--------------------------------------------------------------------------------
/docs/_static/introduction/integration.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/introduction/integration.png
--------------------------------------------------------------------------------
/docs/_static/introduction/introduction.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/introduction/introduction.png
--------------------------------------------------------------------------------
/docs/_static/introduction/overview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/introduction/overview.png
--------------------------------------------------------------------------------
/docs/_static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/logo.png
--------------------------------------------------------------------------------
/docs/_static/optimization_timeframes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/docs/_static/optimization_timeframes.png
--------------------------------------------------------------------------------
/docs/_templates/autosummary/class.rst:
--------------------------------------------------------------------------------
1 | {{ fullname | escape | underline}}
2 |
3 | .. currentmodule:: {{ module }}
4 |
5 | .. autoclass:: {{ objname }}
6 | :members:
7 | :undoc-members:
8 | :show-inheritance:
9 | :inherited-members:
10 |
11 | {% block methods %}
12 | .. automethod:: __init__
13 |
14 | {% if methods %}
15 | .. rubric:: {{ _('Methods') }}
16 |
17 | .. autosummary::
18 | {% for item in methods %}
19 | ~{{ name }}.{{ item }}
20 | {%- endfor %}
21 | {% endif %}
22 | {% endblock %}
23 |
24 | {% block attributes %}
25 | {% if attributes %}
26 | .. rubric:: {{ _('Attributes') }}
27 |
28 | .. autosummary::
29 | {% for item in attributes %}
30 | ~{{ name }}.{{ item }}
31 | {%- endfor %}
32 | {% endif %}
33 | {% endblock %}
34 |
--------------------------------------------------------------------------------
/docs/_templates/autosummary/module.rst:
--------------------------------------------------------------------------------
1 | {{ fullname | escape | underline}}
2 |
3 | .. automodule:: {{ fullname }}
4 |
5 | {% block attributes %}
6 | {% if attributes %}
7 | .. rubric:: Module Attributes
8 |
9 | .. autosummary::
10 | :toctree:
11 | {% for item in attributes %}
12 | {{ item }}
13 | {%- endfor %}
14 | {% endif %}
15 | {% endblock %}
16 |
17 | {% block functions %}
18 | {% if functions %}
19 | .. rubric:: {{ _('Functions') }}
20 |
21 | .. autosummary::
22 | :toctree:
23 | {% for item in functions %}
24 | {{ item }}
25 | {%- endfor %}
26 | {% endif %}
27 | {% endblock %}
28 |
29 | {% block classes %}
30 | {% if classes %}
31 | .. rubric:: {{ _('Classes') }}
32 |
33 | .. autosummary::
34 | :toctree:
35 | :template: autosummary/class.rst
36 | {% for item in classes %}
37 | {{ item }}
38 | {%- endfor %}
39 | {% endif %}
40 | {% endblock %}
41 |
42 | {% block exceptions %}
43 | {% if exceptions %}
44 | .. rubric:: {{ _('Exceptions') }}
45 |
46 | .. autosummary::
47 | :toctree:
48 | {% for item in exceptions %}
49 | {{ item }}
50 | {%- endfor %}
51 | {% endif %}
52 | {% endblock %}
53 |
54 | {% block modules %}
55 | {% if modules %}
56 | .. rubric:: Modules
57 |
58 | .. autosummary::
59 | :toctree:
60 | :template: autosummary/module.rst
61 | :recursive:
62 | {% for item in modules %}
63 | {{ item }}
64 | {%- endfor %}
65 | {% endif %}
66 | {% endblock %}
67 |
--------------------------------------------------------------------------------
/docs/akkudoktoreos/api.rst:
--------------------------------------------------------------------------------
1 | ..
2 | SPDX-License-Identifier: Apache-2.0
3 | File has to be of RST format to make autosummary directive work correctly
4 |
5 | .. _akkudoktoreos_api:
6 |
7 | EOS API
8 | =======
9 |
10 | .. autosummary::
11 | :toctree: _autosummary
12 | :template: autosummary/module.rst
13 | :recursive:
14 |
15 | akkudoktoreos
16 |
--------------------------------------------------------------------------------
/docs/akkudoktoreos/architecture.md:
--------------------------------------------------------------------------------
1 | % SPDX-License-Identifier: Apache-2.0
2 |
3 | # Architecture
4 |
5 | ```{figure} ../_static/architecture-overall.png
6 | :alt: Overall System Architecture
7 |
8 | Overall System Architecture
9 | ```
10 |
11 | ## Overview of the Project Structure
12 |
13 | ## Key Components and Their Roles
14 |
15 | ```{figure} ../_static/architecture-system.png
16 | :alt: EOS Architecture
17 |
18 | EOS Architecture
19 | ```
20 |
21 | ### Configuration
22 |
23 | The configuration controls all aspects of EOS: optimization, prediction, measurement, and energy
24 | management.
25 |
26 | ### Energy Management
27 |
28 | Energy management is the overall process to provide planning data for scheduling the different
29 | devices in your system in an optimal way. Energy management cares for the update of predictions and
30 | the optimization of the planning based on the simulated behavior of the devices. The planning is on
31 | the hour. Sub-hour energy management is left
32 |
33 | ### Optimization
34 |
35 | ### Device Simulations
36 |
37 | Device simulations simulate devices' behavior based on internal logic and predicted data. They
38 | provide the data needed for optimization.
39 |
40 | ### Predictions
41 |
42 | Predictions provide predicted future data to be used by the optimization.
43 |
44 | ### Measurements
45 |
46 | Measurements are utilized to refine predictions using real data from your system, thereby enhancing
47 | accuracy.
48 |
49 | ### EOS Server
50 |
51 | EOS operates as a [REST](https://en.wikipedia.org/wiki/REST) [API](https://restfulapi.net/) server.
52 |
53 | ### EOSdash
54 |
55 | `EOSdash` is a lightweight support dashboard for EOS. It is pre-integrated with EOS. When enabled,
56 | it can be accessed by navigating to [http://localhost:8503](http://localhost:8503) in your browser.
57 |
--------------------------------------------------------------------------------
/docs/akkudoktoreos/configuration.md:
--------------------------------------------------------------------------------
1 | % SPDX-License-Identifier: Apache-2.0
2 |
3 | # Configuration
4 |
5 | The configuration controls all aspects of EOS: optimization, prediction, measurement, and energy
6 | management.
7 |
8 | ## Storing Configuration
9 |
10 | EOS stores configuration data in a `nested structure`. Note that configuration changes inside EOS
11 | are updated in memory, meaning all changes will be lost upon restarting the EOS REST server if not
12 | saved to the `EOS configuration file`.
13 |
14 | Some `configuration keys` are read-only and cannot be altered. These keys are either set up by other
15 | means, such as environment variables, or determined from other information.
16 |
17 | Several endpoints of the EOS REST server allow for the management and retrieval of configuration
18 | data.
19 |
20 | ### Save Configuration File
21 |
22 | Use endpoint `PUT /v1/config/file` to save the current configuration to the
23 | `EOS configuration file`.
24 |
25 | ### Load Configuration File
26 |
27 | Use endpoint `POST /v1/config/reset` to reset the configuration to the values in the
28 | `EOS configuration file`.
29 |
30 | ## Configuration Sources and Priorities
31 |
32 | The configuration sources and their priorities are as follows:
33 |
34 | 1. `Settings`: Provided during runtime by the REST interface
35 | 2. `Environment Variables`: Defined at startup of the REST server and during runtime
36 | 3. `EOS Configuration File`: Read at startup of the REST server and on request
37 | 4. `Default Values`
38 |
39 | ### Runtime Config Updates
40 |
41 | The EOS configuration can be updated at runtime. Note that those updates are not persistent
42 | automatically. However it is possible to save the configuration to the `EOS configuration file`.
43 |
44 | Use the following endpoints to change the current runtime configuration:
45 |
46 | - `PUT /v1/config`: Update the entire or parts of the configuration.
47 |
48 | ### Environment Variables
49 |
50 | All `configuration keys` can be set by environment variables prefixed with `EOS_` and separated by
51 | `__` for nested structures. Environment variables are case insensitive.
52 |
53 | EOS recognizes the following special environment variables (case sensitive):
54 |
55 | - `EOS_CONFIG_DIR`: The directory to search for an EOS configuration file.
56 | - `EOS_DIR`: The directory used by EOS for data, which will also be searched for an EOS
57 | configuration file.
58 |
59 | ### EOS Configuration File
60 |
61 | The EOS configuration file provides persistent storage for configuration data. It can be modified
62 | directly or through the REST interface.
63 |
64 | If you do not have a configuration file, it will be automatically created on the first startup of
65 | the REST server in a system-dependent location.
66 |
67 | To determine the location of the configuration file used by EOS, ask the REST server. The endpoint
68 | `GET /v1/config` provides the `general.config_file_path` configuration key.
69 |
70 | EOS searches for the configuration file in the following order:
71 |
72 | 1. The directory specified by the `EOS_CONFIG_DIR` environment variable
73 | 2. The directory specified by the `EOS_DIR` environment variable
74 | 3. A platform-specific default directory for EOS
75 | 4. The current working directory
76 |
77 | The first configuration file available in these directories is loaded. If no configuration file is
78 | found, a default configuration file is created, and the default settings are written to it. The
79 | location of the created configuration file follows the same order in which EOS searches for
80 | configuration files, and it depends on whether the relevant environment variables are set.
81 |
82 | Use the following endpoints to interact with the configuration file:
83 |
84 | - `PUT /v1/config/file`: Save the current configuration to the configuration file.
85 | - `PUT /v1/config/reset`: Reload the configuration file, all unsaved runtime configuration is reset.
86 |
87 | ### Default Values
88 |
89 | Some of the `configuration keys` have default values by definition. For most of the
90 | `configuration keys` the default value is just `None`, which means no default value.
91 |
92 | ```{include} /_generated/config.md
93 | :heading-offset: 1
94 | :relative-docs: ..
95 | :relative-images:
96 | ```
97 |
--------------------------------------------------------------------------------
/docs/akkudoktoreos/integration.md:
--------------------------------------------------------------------------------
1 | % SPDX-License-Identifier: Apache-2.0
2 | (integration-page)=
3 |
4 | # Integration
5 |
6 | EOS operates as a [REST](https://en.wikipedia.org/wiki/REST) [API](https://restfulapi.net/) server,
7 | allowing for seamless integration with a wide range of home automation systems.
8 |
9 | ## EOSdash
10 |
11 | `EOSdash` is a lightweight support dashboard for EOS. It is pre-integrated with EOS. When enabled,
12 | it can be accessed by navigating to [http://localhost:8503](http://localhost:8503) in your browser.
13 |
14 | ## Node-RED
15 |
16 | [Node-RED](https://nodered.org/) is a programming tool designed for connecting hardware devices,
17 | APIs, and online services in creative and practical ways.
18 |
19 | Andreas Schmitz uses [Node-RED](https://nodered.org/) as part of his home automation setup.
20 |
21 | ### Node-Red Resources
22 |
23 | - [Installation Guide (German)](https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/)
24 | \— A detailed guide on integrating an early version of EOS with `Node-RED`.
25 |
26 | ## Home Assistant
27 |
28 | [Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform that
29 | emphasizes local control and user privacy.
30 |
31 | (duetting-solution)=
32 |
33 | ### Home Assistant Resources
34 |
35 | - Duetting's [EOS Home Assistant Addon](https://github.com/Duetting/ha_eos_addon) — Additional
36 | details can be found in this [discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).
37 |
--------------------------------------------------------------------------------
/docs/akkudoktoreos/measurement.md:
--------------------------------------------------------------------------------
1 | % SPDX-License-Identifier: Apache-2.0
2 |
3 | # Measurements
4 |
5 | Measurements are utilized to refine predictions using real data from your system, thereby enhancing
6 | accuracy.
7 |
8 | - Household Load Measurement
9 | - Grid Export Measurement
10 | - Grid Import Measurement
11 |
12 | ## Storing Measurements
13 |
14 | EOS stores measurements in a **key-value store**, where the term `measurement key` refers to the
15 | unique identifier used to store and retrieve specific measurement data. Note that the key-value
16 | store is memory-based, meaning that all stored data will be lost upon restarting the EOS REST
17 | server.
18 |
19 | :::{admonition} Todo
20 | :class: note
21 | Ensure that measurement data persists across server restarts.
22 | :::
23 |
24 | Several endpoints of the EOS REST server allow for the management and retrieval of these
25 | measurements.
26 |
27 | The measurement data must be or is provided in one of the following formats:
28 |
29 | ### 1. DateTimeData
30 |
31 | A dictionary with the following structure:
32 |
33 | ```python
34 | {
35 | "start_datetime": "2024-01-01 00:00:00",
36 | "interval": "1 Hour",
37 | "": [value, value, ...],
38 | "": [value, value, ...],
39 | ...
40 | }
41 | ```
42 |
43 | ### 2. DateTimeDataFrame
44 |
45 | A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html) dataframe with a
46 | `DatetimeIndex`. Use [pandas.DataFrame.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_json.html#pandas.DataFrame.to_json).
47 | The column name of the data must be the same as the names of the `measurement key`s.
48 |
49 | ### 3. DateTimeSeries
50 |
51 | A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html) series with a
52 | `DatetimeIndex`. Use [pandas.Series.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.Series.to_json.html#pandas.Series.to_json).
53 |
54 | ## Load Measurement
55 |
56 | The EOS measurement store provides for storing meter readings of loads. There are currently five loads
57 | foreseen. The associated `measurement key`s are:
58 |
59 | - `load0_mr`: Load0 meter reading [kWh]
60 | - `load1_mr`: Load1 meter reading [kWh]
61 | - `load2_mr`: Load2 meter reading [kWh]
62 | - `load3_mr`: Load3 meter reading [kWh]
63 | - `load4_mr`: Load4 meter reading [kWh]
64 |
65 | For ease of use, you can assign descriptive names to the `measurement key`s to represent your
66 | system's load sources. Use the following `configuration options` to set these names
67 | (e.g., 'Dish Washer', 'Heat Pump'):
68 |
69 | - `load0_name`: Name of the load0 source
70 | - `load1_name`: Name of the load1 source
71 | - `load2_name`: Name of the load2 source
72 | - `load3_name`: Name of the load3 source
73 | - `load4_name`: Name of the load4 source
74 |
75 | Load measurements can be stored for any datetime. The values between different meter readings are
76 | linearly approximated. Since optimization occurs on the hour, storing values between hours is
77 | generally not useful.
78 |
79 | The EOS measurement store automatically sums all given loads to create a total load value series
80 | for specified intervals, usually one hour. This aggregated data can be used for load predictions.
81 |
82 | ## Grid Export/ Import Measurement
83 |
84 | The EOS measurement store also allows for the storage of meter readings for grid import and export.
85 | The associated `measurement key`s are:
86 |
87 | - `grid_export_mr`: Export to grid meter reading [kWh]
88 | - `grid_import_mr`: Import from grid meter reading [kWh]
89 |
90 | :::{admonition} Todo
91 | :class: note
92 | Currently not used. Integrate grid meter readings into the respective predictions.
93 | :::
94 |
--------------------------------------------------------------------------------
/docs/akkudoktoreos/serverapi.md:
--------------------------------------------------------------------------------
1 | % SPDX-License-Identifier: Apache-2.0
2 |
3 | # Server API
4 |
5 | ```{include} /_generated/openapi.md
6 | :start-line: 2
7 | :relative-docs: ..
8 | :relative-images:
9 | ```
10 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | """Configuration file for the Sphinx documentation builder.
2 |
3 | For the full list of built-in configuration values, see the documentation:
4 | https://www.sphinx-doc.org/en/master/usage/configuration.html
5 | """
6 |
7 | import sys
8 | from pathlib import Path
9 |
10 | # -- Project information -----------------------------------------------------
11 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
12 |
13 | project = "Akkudoktor EOS"
14 | copyright = "2024, Andreas Schmitz"
15 | author = "Andreas Schmitz"
16 | release = "0.0.1"
17 |
18 | # -- General configuration ---------------------------------------------------
19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
20 |
21 | extensions = [
22 | "sphinx.ext.autodoc",
23 | "sphinx.ext.autosummary",
24 | "sphinx.ext.napoleon",
25 | "sphinx_rtd_theme",
26 | "myst_parser",
27 | "sphinx_tabs.tabs",
28 | ]
29 |
30 | templates_path = ["_templates"]
31 | exclude_patterns = []
32 |
33 | source_suffix = {
34 | ".rst": "restructuredtext",
35 | ".txt": "markdown",
36 | ".md": "markdown",
37 | }
38 |
39 | # -- Options for Myst Markdown -----------------------------------------------
40 | # see https://github.com/executablebooks/MyST-Parser/blob/master/docs/conf.py
41 |
42 | myst_enable_extensions = [
43 | "dollarmath",
44 | "amsmath",
45 | "deflist",
46 | "fieldlist",
47 | "html_admonition",
48 | "html_image",
49 | "colon_fence",
50 | "smartquotes",
51 | "replacements",
52 | "linkify",
53 | "strikethrough",
54 | "substitution",
55 | "tasklist",
56 | "attrs_inline",
57 | "attrs_block",
58 | ]
59 | myst_url_schemes = {
60 | "http": None,
61 | "https": None,
62 | "mailto": None,
63 | "ftp": None,
64 | "wiki": "https://en.wikipedia.org/wiki/{{path}}#{{fragment}}",
65 | "doi": "https://doi.org/{{path}}",
66 | "gh-pr": {
67 | "url": "https://github.com/Akkudoktor-EOS/EOS/pull/{{path}}#{{fragment}}",
68 | "title": "PR #{{path}}",
69 | "classes": ["github"],
70 | },
71 | "gh-issue": {
72 | "url": "https://github.com/Akkudoktor-EOS/EOS/issue/{{path}}#{{fragment}}",
73 | "title": "Issue #{{path}}",
74 | "classes": ["github"],
75 | },
76 | "gh-user": {
77 | "url": "https://github.com/{{path}}",
78 | "title": "@{{path}}",
79 | "classes": ["github"],
80 | },
81 | }
82 | myst_number_code_blocks = ["typescript"]
83 | myst_heading_anchors = 3
84 | myst_footnote_transition = True
85 | myst_dmath_double_inline = True
86 | myst_enable_checkboxes = True
87 | myst_substitutions = {
88 | "role": "[role](#syntax/roles)",
89 | "directive": "[directive](#syntax/directives)",
90 | }
91 |
92 | # -- Options for HTML output -------------------------------------------------
93 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
94 |
95 | html_theme = "sphinx_rtd_theme"
96 | html_static_path = ["_static"]
97 | html_logo = "_static/logo.png"
98 | html_theme_options = {
99 | "logo_only": False,
100 | "titles_only": True,
101 | }
102 | html_css_files = ["eos.css"]
103 |
104 | # -- Options for autodoc -------------------------------------------------
105 | # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html
106 |
107 | # Make source file directories available to sphinx
108 | sys.path.insert(0, str(Path("..", "src").resolve()))
109 |
110 | autodoc_default_options = {
111 | "members": "var1, var2",
112 | "member-order": "bysource",
113 | "special-members": "__init__",
114 | "undoc-members": True,
115 | "exclude-members": "__weakref__",
116 | }
117 |
118 | # -- Options for autosummary -------------------------------------------------
119 | autosummary_generate = True
120 |
121 | # -- Options for napoleon -------------------------------------------------
122 | napoleon_google_docstring = True
123 | napoleon_numpy_docstring = False
124 | napoleon_include_init_with_doc = False
125 | napoleon_include_private_with_doc = False
126 | napoleon_include_special_with_doc = True
127 | napoleon_use_admonition_for_examples = False
128 | napoleon_use_admonition_for_notes = False
129 | napoleon_use_admonition_for_references = False
130 | napoleon_use_ivar = False
131 | napoleon_use_param = True
132 | napoleon_use_rtype = True
133 | napoleon_preprocess_types = False
134 | napoleon_type_aliases = None
135 | napoleon_attr_annotations = True
136 |
--------------------------------------------------------------------------------
/docs/develop/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ```{include} ../../CONTRIBUTING.md
2 | :relative-docs: ../
3 | :relative-images:
4 | ```
5 |
--------------------------------------------------------------------------------
/docs/develop/getting_started.md:
--------------------------------------------------------------------------------
1 | # Getting Started
2 |
3 | ## Installation
4 |
5 | The project requires Python 3.10 or newer. Currently there are no official packages or images published.
6 |
7 | Following sections describe how to locally start the EOS server on `http://localhost:8503`.
8 |
9 | ### Run from source
10 |
11 | Install the dependencies in a virtual environment:
12 |
13 | ```{eval-rst}
14 | .. tabs::
15 |
16 | .. tab:: Windows
17 |
18 | .. code-block:: powershell
19 |
20 | python -m venv .venv
21 | .venv\Scripts\pip install -r requirements.txt
22 | .venv\Scripts\pip install -e .
23 |
24 | .. tab:: Linux
25 |
26 | .. code-block:: bash
27 |
28 | python -m venv .venv
29 | .venv/bin/pip install -r requirements.txt
30 | .venv/bin/pip install -e .
31 |
32 | ```
33 |
34 | Start the EOS fastapi server:
35 |
36 | ```{eval-rst}
37 | .. tabs::
38 |
39 | .. tab:: Windows
40 |
41 | .. code-block:: powershell
42 |
43 | .venv\Scripts\python src/akkudoktoreos/server/eos.py
44 |
45 | .. tab:: Linux
46 |
47 | .. code-block:: bash
48 |
49 | .venv/bin/python src/akkudoktoreos/server/eos.py
50 |
51 | ```
52 |
53 | ### Docker
54 |
55 | ```{eval-rst}
56 | .. tabs::
57 |
58 | .. tab:: Windows
59 |
60 | .. code-block:: powershell
61 |
62 | docker compose up --build
63 |
64 | .. tab:: Linux
65 |
66 | .. code-block:: bash
67 |
68 | docker compose up --build
69 |
70 | ```
71 |
72 | ## Configuration
73 |
74 | This project uses the `EOS.config.json` file to manage configuration settings.
75 |
76 | ### Default Configuration
77 |
78 | A default configuration file `default.config.json` is provided. This file contains all the necessary
79 | configuration keys with their default values.
80 |
81 | ### Custom Configuration
82 |
83 | Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`.
84 |
85 | - If the directory specified by `EOS_DIR` contains an existing `EOS.config.json` file, the
86 | application will use this configuration file.
87 | - If the `EOS.config.json` file does not exist in the specified directory, the `default.config.json`
88 | file will be copied to the directory as `EOS.config.json`.
89 |
90 | ### Configuration Updates
91 |
92 | If the configuration keys in the `EOS.config.json` file are missing or different from those in
93 | `default.config.json`, they will be automatically updated to match the default settings, ensuring
94 | that all required keys are present.
95 |
96 | ## Classes and Functionalities
97 |
98 | This project uses various classes to simulate and optimize the components of an energy system. Each
99 | class represents a specific aspect of the system, as described below:
100 |
101 | - `Battery`: Simulates a battery storage system, including capacity, state of charge, and now
102 | charge and discharge losses.
103 |
104 | - `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and
105 | historical generation data.
106 |
107 | - `Load`: Models the load requirements of a household or business, enabling the prediction of future
108 | energy demand.
109 |
110 | - `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various
111 | operating conditions.
112 |
113 | - `Strompreis`: Provides information on electricity prices, enabling optimization of energy
114 | consumption and generation based on tariff information.
115 |
116 | - `EMS`: The Energy Management System (EMS) coordinates the interaction between the various
117 | components, performs optimization, and simulates the operation of the entire energy system.
118 |
119 | These classes work together to enable a detailed simulation and optimization of the energy system.
120 | For each class, specific parameters and settings can be adjusted to test different scenarios and
121 | strategies.
122 |
123 | ### Customization and Extension
124 |
125 | Each class is designed to be easily customized and extended to integrate additional functions or
126 | improvements. For example, new methods can be added for more accurate modeling of PV system or
127 | battery behavior. Developers are invited to modify and extend the system according to their needs.
128 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | % SPDX-License-Identifier: Apache-2.0
2 |
3 | ```{image} _static/logo.png
4 |
5 | ```
6 |
7 | # Akkudoktor EOS documentation
8 |
9 | ```{toctree}
10 | :maxdepth: 2
11 | :caption: Overview
12 |
13 | akkudoktoreos/introduction.md
14 |
15 | ```
16 |
17 | ```{toctree}
18 | :maxdepth: 2
19 | :caption: Tutorials
20 |
21 | develop/getting_started.md
22 |
23 | ```
24 |
25 | ```{toctree}
26 | :maxdepth: 2
27 | :caption: How-To Guides
28 |
29 | develop/CONTRIBUTING.md
30 |
31 | ```
32 |
33 | ```{toctree}
34 | :maxdepth: 2
35 | :caption: Reference
36 |
37 | akkudoktoreos/architecture.md
38 | akkudoktoreos/configuration.md
39 | akkudoktoreos/optimization.md
40 | akkudoktoreos/prediction.md
41 | akkudoktoreos/measurement.md
42 | akkudoktoreos/integration.md
43 | akkudoktoreos/serverapi.md
44 | akkudoktoreos/api.rst
45 |
46 | ```
47 |
48 | ## Indices and tables
49 |
50 | - {ref}`genindex`
51 | - {ref}`modindex`
52 | - {ref}`search`
53 |
--------------------------------------------------------------------------------
/docs/pymarkdown.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": {
3 | "md007": {
4 | "enabled": true,
5 | "code_block_line_length" : 160
6 | },
7 | "md013": {
8 | "enabled": true,
9 | "line_length" : 120
10 | },
11 | "md041": {
12 | "enabled": false
13 | }
14 | },
15 | "extensions": {
16 | "front-matter" : {
17 | "enabled" : true
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/docs/welcome.md:
--------------------------------------------------------------------------------
1 | % SPDX-License-Identifier: Apache-2.0
2 |
3 | # Welcome to the EOS documentation
4 |
5 | This documentation is continuously written. It is edited via text files in the
6 | [Markdown/ Markedly Structured Text](https://myst-parser.readthedocs.io/en/latest/index.html)
7 | markup language and then compiled into a static website/ offline document using the open source tool
8 | [Sphinx](https://www.sphinx-doc.org) and is available on
9 | [Read the Docs](https://akkudoktor-eos.readthedocs.io/en/latest/).
10 |
11 | You can contribute to EOS's documentation by opening
12 | [GitHub issues](https://github.com/Akkudoktor-EOS/EOS/issues)
13 | or sending patches via pull requests on its
14 | [GitHub repository](https://github.com/Akkudoktor-EOS/EOS).
15 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "akkudoktor-eos"
3 | version = "0.0.1"
4 | authors = [
5 | { name="Andreas Schmitz", email="author@example.com" },
6 | ]
7 | 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."
8 | readme = "README.md"
9 | license = {file = "LICENSE"}
10 | requires-python = ">=3.11"
11 | classifiers = [
12 | "Development Status :: 3 - Alpha",
13 | "Programming Language :: Python :: 3",
14 | "Operating System :: OS Independent",
15 | ]
16 |
17 | [project.urls]
18 | Homepage = "https://github.com/Akkudoktor-EOS/EOS"
19 | Issues = "https://github.com/Akkudoktor-EOS/EOS/issues"
20 |
21 | [build-system]
22 | requires = ["setuptools>=61.0"]
23 | build-backend = "setuptools.build_meta"
24 |
25 | [tool.setuptools.dynamic]
26 | dependencies = {file = ["requirements.txt"]}
27 | optional-dependencies = {dev = { file = ["requirements-dev.txt"] }}
28 |
29 | [tool.setuptools.packages.find]
30 | where = ["src/"]
31 | include = ["akkudoktoreos"]
32 |
33 | [tool.setuptools.package-data]
34 | akkudoktoreos = ["*.json", "data/*.npz", ]
35 |
36 | [tool.pyright]
37 | # used in Pylance extension for language server
38 | # type check is done by mypy, disable to avoid unwanted errors
39 | typeCheckingMode = "off"
40 |
41 | [tool.isort]
42 | profile = "black"
43 |
44 | [tool.ruff]
45 | line-length = 100
46 | exclude = [
47 | "tests",
48 | "scripts",
49 | ]
50 | output-format = "full"
51 |
52 | [tool.ruff.lint]
53 | select = [
54 | "F", # Enable all `Pyflakes` rules.
55 | "D", # Enable all `pydocstyle` rules, limiting to those that adhere to the
56 | # Google convention via `convention = "google"`, below.
57 | "S", # Enable all `flake8-bandit` rules.
58 | ]
59 | ignore = [
60 | # Prevent errors due to ruff false positives
61 | # ------------------------------------------
62 | # On top of `Pyflakes (F)` to allow numpydantic Shape forward annotation
63 | "F722", # forward-annotation-syntax-error: forward annotations that include invalid syntax.
64 |
65 | # Prevent errors for existing sources. Should be removed!!!
66 | # ---------------------------------------------------------
67 | # On top of `Pyflakes (F)`
68 | "F841", # unused-variable: Local variable {name} is assigned to but never used
69 | # On top of `pydocstyle (D)`
70 | "D100", # undocumented-public-module: Missing docstring in public module
71 | "D101", # undocumented-public-class: Missing docstring in public class
72 | "D102", # undocumented-public-method: Missing docstring in public method
73 | "D103", # undocumented-public-function: Missing docstring in public function
74 | "D104", # undocumented-public-package: Missing docstring in public package
75 | "D105", # undocumented-magic-method: Missing docstring in magic method
76 | "D106", # undocumented-public-nested-class: Missing docstring in public nested class
77 | "D107", # undocumented-public-init: Missing docstring in __init__
78 | "D417", # undocumented-param: Missing argument description in the docstring for {definition}: {name}
79 | ]
80 |
81 | [tool.ruff.lint.pydocstyle]
82 | convention = "google"
83 |
84 | [tool.pytest.ini_options]
85 | minversion = "8.3.3"
86 | pythonpath = [ "src", ]
87 | testpaths = [ "tests", ]
88 |
89 | [tool.mypy]
90 | files = ["src", "tests"]
91 | exclude = "class_soc_calc\\.py$"
92 | check_untyped_defs = true
93 | warn_unused_ignores = true
94 |
95 | [[tool.mypy.overrides]]
96 | module = "akkudoktoreos.*"
97 | disallow_untyped_defs = true
98 |
99 | [[tool.mypy.overrides]]
100 | module = "sklearn.*"
101 | ignore_missing_imports = true
102 |
103 | [[tool.mypy.overrides]]
104 | module = "deap.*"
105 | ignore_missing_imports = true
106 |
107 | [[tool.mypy.overrides]]
108 | module = "xprocess.*"
109 | ignore_missing_imports = true
110 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | -r requirements.txt
2 | gitlint==0.19.1
3 | GitPython==3.1.44
4 | myst-parser==4.0.1
5 | sphinx==8.2.3
6 | sphinx_rtd_theme==3.0.2
7 | sphinx-tabs==3.4.7
8 | pymarkdownlnt==0.9.30
9 | pytest==8.4.0
10 | pytest-cov==6.1.1
11 | pytest-xprocess==1.0.2
12 | pre-commit
13 | mypy==1.16.0
14 | types-requests==2.32.0.20250602
15 | pandas-stubs==2.2.3.250527
16 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | cachebox==5.0.1
2 | numpy==2.2.6
3 | numpydantic==1.6.9
4 | matplotlib==3.10.3
5 | fastapi[standard]==0.115.12
6 | python-fasthtml==0.12.19
7 | MonsterUI==1.0.21
8 | markdown-it-py==3.0.0
9 | mdit-py-plugins==0.4.2
10 | bokeh==3.7.2
11 | uvicorn==0.34.3
12 | scikit-learn==1.6.1
13 | timezonefinder==6.5.9
14 | deap==1.4.3
15 | requests==2.32.3
16 | pandas==2.2.3
17 | pendulum==3.1.0
18 | platformdirs==4.3.8
19 | psutil==7.0.0
20 | pvlib==0.12.0
21 | pydantic==2.11.5
22 | statsmodels==0.14.4
23 | pydantic-settings==2.9.1
24 | linkify-it-py==2.0.3
25 |
--------------------------------------------------------------------------------
/scripts/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/scripts/__init__.py
--------------------------------------------------------------------------------
/scripts/generate_openapi.py:
--------------------------------------------------------------------------------
1 | #!.venv/bin/python
2 | """This module generates the OpenAPI specification for the EOS application defined in `akkudoktoreos.server.eos`.
3 |
4 | The script can be executed directly to generate the OpenAPI specification
5 | either to the standard output or to a specified file.
6 |
7 | Usage:
8 | scripts/generate_openapi.py [--output-file OUTPUT_FILE]
9 |
10 | Arguments:
11 | --output-file : Optional. The file path to write the OpenAPI specification to.
12 |
13 | Example:
14 | scripts/generate_openapi.py --output-file openapi.json
15 | """
16 |
17 | import argparse
18 | import json
19 | import os
20 | import sys
21 |
22 | from fastapi.openapi.utils import get_openapi
23 |
24 | from akkudoktoreos.server.eos import app
25 |
26 |
27 | def generate_openapi() -> dict:
28 | """Generate the OpenAPI specification.
29 |
30 | Returns:
31 | openapi_spec (dict): OpenAPI specification.
32 | """
33 | openapi_spec = get_openapi(
34 | title=app.title,
35 | version=app.version,
36 | openapi_version=app.openapi_version,
37 | description=app.description,
38 | routes=app.routes,
39 | )
40 |
41 | # Fix file path for general settings to not show local/test file path
42 | general = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["general"]["default"]
43 | general["config_file_path"] = "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
44 | general["config_folder_path"] = "/home/user/.config/net.akkudoktoreos.net"
45 |
46 | return openapi_spec
47 |
48 |
49 | def main():
50 | """Main function to run the generation of the OpenAPI specification."""
51 | parser = argparse.ArgumentParser(description="Generate OpenAPI Specification")
52 | parser.add_argument(
53 | "--output-file", type=str, default=None, help="File to write the OpenAPI Specification to"
54 | )
55 |
56 | args = parser.parse_args()
57 |
58 | try:
59 | openapi_spec = generate_openapi()
60 | openapi_spec_str = json.dumps(openapi_spec, indent=2)
61 | if args.output_file:
62 | # Write to file
63 | with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
64 | f.write(openapi_spec_str)
65 | else:
66 | # Write to std output
67 | print(openapi_spec_str)
68 |
69 | except Exception as e:
70 | print(f"Error during OpenAPI specification generation: {e}", file=sys.stderr)
71 | sys.exit(1)
72 |
73 |
74 | if __name__ == "__main__":
75 | main()
76 |
--------------------------------------------------------------------------------
/scripts/gitlint/eos_commit_rules.py:
--------------------------------------------------------------------------------
1 | # Placeholder for gitlint user rules (see https://jorisroovers.com/gitlint/latest/rules/user_defined_rules/).
2 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/config/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/config/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/config/configabc.py:
--------------------------------------------------------------------------------
1 | """Abstract and base classes for configuration."""
2 |
3 | from typing import Any, ClassVar
4 |
5 | from akkudoktoreos.core.pydantic import PydanticBaseModel
6 |
7 |
8 | class SettingsBaseModel(PydanticBaseModel):
9 | """Base model class for all settings configurations."""
10 |
11 | # EOS configuration - set by ConfigEOS
12 | config: ClassVar[Any] = None
13 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/core/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/core/cachesettings.py:
--------------------------------------------------------------------------------
1 | """Settings for caching.
2 |
3 | Kept in an extra module to avoid cyclic dependencies on package import.
4 | """
5 |
6 | from pathlib import Path
7 | from typing import Optional
8 |
9 | from pydantic import Field
10 |
11 | from akkudoktoreos.config.configabc import SettingsBaseModel
12 |
13 |
14 | class CacheCommonSettings(SettingsBaseModel):
15 | """Cache Configuration."""
16 |
17 | subpath: Optional[Path] = Field(
18 | default="cache", description="Sub-path for the EOS cache data directory."
19 | )
20 |
21 | cleanup_interval: float = Field(
22 | default=5 * 60, description="Intervall in seconds for EOS file cache cleanup."
23 | )
24 |
25 | # Do not make this a pydantic computed field. The pydantic model must be fully initialized
26 | # to have access to config.general, which may not be the case if it is a computed field.
27 | def path(self) -> Optional[Path]:
28 | """Compute cache path based on general.data_folder_path."""
29 | data_cache_path = self.config.general.data_folder_path
30 | if data_cache_path is None or self.subpath is None:
31 | return None
32 | return data_cache_path.joinpath(self.subpath)
33 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/core/decorators.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Callable
2 | from typing import Any, Optional
3 |
4 | from akkudoktoreos.core.logging import get_logger
5 |
6 | logger = get_logger(__name__)
7 |
8 |
9 | class classproperty:
10 | """A decorator to define a read-only property at the class level.
11 |
12 | This class replaces the built-in `property` which is no longer available in
13 | combination with @classmethod since Python 3.13 to allow a method to be
14 | accessed as a property on the class itself, rather than an instance. This
15 | is useful when you want a property-like syntax for methods that depend on
16 | the class rather than any instance of the class.
17 |
18 | Example:
19 | class MyClass:
20 | _value = 42
21 |
22 | @classproperty
23 | def value(cls):
24 | return cls._value
25 |
26 | print(MyClass.value) # Outputs: 42
27 |
28 | Methods:
29 | __get__: Retrieves the value of the class property by calling the
30 | decorated method on the class.
31 |
32 | Parameters:
33 | fget (Callable[[Any], Any]): A method that takes the class as an
34 | argument and returns a value.
35 |
36 | Raises:
37 | RuntimeError: If `fget` is not defined when `__get__` is called.
38 | """
39 |
40 | def __init__(self, fget: Callable[[Any], Any]) -> None:
41 | self.fget = fget
42 |
43 | def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any:
44 | if owner_cls is None:
45 | return self
46 | if self.fget is None:
47 | raise RuntimeError("'fget' not defined when `__get__` is called")
48 | return self.fget(owner_cls)
49 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/core/emsettings.py:
--------------------------------------------------------------------------------
1 | """Settings for energy management.
2 |
3 | Kept in an extra module to avoid cyclic dependencies on package import.
4 | """
5 |
6 | from typing import Optional
7 |
8 | from pydantic import Field
9 |
10 | from akkudoktoreos.config.configabc import SettingsBaseModel
11 |
12 |
13 | class EnergyManagementCommonSettings(SettingsBaseModel):
14 | """Energy Management Configuration."""
15 |
16 | startup_delay: float = Field(
17 | default=5,
18 | ge=1,
19 | description="Startup delay in seconds for EOS energy management runs.",
20 | )
21 |
22 | interval: Optional[float] = Field(
23 | default=None,
24 | description="Intervall in seconds between EOS energy management runs.",
25 | examples=["300"],
26 | )
27 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/core/logabc.py:
--------------------------------------------------------------------------------
1 | """Abstract and base classes for logging."""
2 |
3 | import logging
4 |
5 |
6 | def logging_str_to_level(level_str: str) -> int:
7 | """Convert log level string to logging level."""
8 | if level_str == "DEBUG":
9 | level = logging.DEBUG
10 | elif level_str == "INFO":
11 | level = logging.INFO
12 | elif level_str == "WARNING":
13 | level = logging.WARNING
14 | elif level_str == "CRITICAL":
15 | level = logging.CRITICAL
16 | elif level_str == "ERROR":
17 | level = logging.ERROR
18 | else:
19 | raise ValueError(f"Unknown loggin level: {level_str}")
20 | return level
21 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/core/logging.py:
--------------------------------------------------------------------------------
1 | """Utility functions for handling logging tasks.
2 |
3 | Functions:
4 | ----------
5 | - get_logger: Creates and configures a logger with console and optional rotating file logging.
6 |
7 | Example usage:
8 | --------------
9 | # Logger setup
10 | >>> logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
11 | >>> logger.info("Logging initialized.")
12 |
13 | Notes:
14 | ------
15 | - The logger supports rotating log files to prevent excessive log file size.
16 | """
17 |
18 | import logging as pylogging
19 | import os
20 | from logging.handlers import RotatingFileHandler
21 | from typing import Optional
22 |
23 | from akkudoktoreos.core.logabc import logging_str_to_level
24 |
25 |
26 | def get_logger(
27 | name: str,
28 | log_file: Optional[str] = None,
29 | logging_level: Optional[str] = None,
30 | max_bytes: int = 5000000,
31 | backup_count: int = 5,
32 | ) -> pylogging.Logger:
33 | """Creates and configures a logger with a given name.
34 |
35 | The logger supports logging to both the console and an optional log file. File logging is
36 | handled by a rotating file handler to prevent excessive log file size.
37 |
38 | Args:
39 | name (str): The name of the logger, typically `__name__` from the calling module.
40 | log_file (Optional[str]): Path to the log file for file logging. If None, no file logging is done.
41 | logging_level (Optional[str]): Logging level (e.g., "INFO", "DEBUG"). Defaults to "INFO".
42 | max_bytes (int): Maximum size in bytes for log file before rotation. Defaults to 5 MB.
43 | backup_count (int): Number of backup log files to keep. Defaults to 5.
44 |
45 | Returns:
46 | logging.Logger: Configured logger instance.
47 |
48 | Example:
49 | logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
50 | logger.info("Application started")
51 | """
52 | # Create a logger with the specified name
53 | logger = pylogging.getLogger(name)
54 | logger.propagate = True
55 | # This is already supported by pydantic-settings in LoggingCommonSettings, however in case
56 | # loading the config itself fails and to set the level before we load the config, we set it here manually.
57 | if logging_level is None and (env_level := os.getenv("EOS_LOGGING__LEVEL")) is not None:
58 | logging_level = env_level
59 | if logging_level is not None:
60 | level = logging_str_to_level(logging_level)
61 | logger.setLevel(level)
62 |
63 | # The log message format
64 | formatter = pylogging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
65 |
66 | # Prevent loggers from being added multiple times
67 | # There may already be a logger from pytest
68 | if not logger.handlers:
69 | # Create a console handler with a standard output stream
70 | console_handler = pylogging.StreamHandler()
71 | if logging_level is not None:
72 | console_handler.setLevel(level)
73 | console_handler.setFormatter(formatter)
74 |
75 | # Add the console handler to the logger
76 | logger.addHandler(console_handler)
77 |
78 | if log_file and len(logger.handlers) < 2: # We assume a console logger to be the first logger
79 | # If a log file path is specified, create a rotating file handler
80 |
81 | # Ensure the log directory exists
82 | log_dir = os.path.dirname(log_file)
83 | if log_dir and not os.path.exists(log_dir):
84 | os.makedirs(log_dir)
85 |
86 | # Create a rotating file handler
87 | file_handler = RotatingFileHandler(log_file, maxBytes=max_bytes, backupCount=backup_count)
88 | if logging_level is not None:
89 | file_handler.setLevel(level)
90 | file_handler.setFormatter(formatter)
91 |
92 | # Add the file handler to the logger
93 | logger.addHandler(file_handler)
94 |
95 | return logger
96 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/core/logsettings.py:
--------------------------------------------------------------------------------
1 | """Settings for logging.
2 |
3 | Kept in an extra module to avoid cyclic dependencies on package import.
4 | """
5 |
6 | import logging
7 | from typing import Optional
8 |
9 | from pydantic import Field, computed_field, field_validator
10 |
11 | from akkudoktoreos.config.configabc import SettingsBaseModel
12 | from akkudoktoreos.core.logabc import logging_str_to_level
13 |
14 |
15 | class LoggingCommonSettings(SettingsBaseModel):
16 | """Logging Configuration."""
17 |
18 | level: Optional[str] = Field(
19 | default=None,
20 | description="EOS default logging level.",
21 | examples=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"],
22 | )
23 |
24 | # Validators
25 | @field_validator("level", mode="after")
26 | @classmethod
27 | def set_default_logging_level(cls, value: Optional[str]) -> Optional[str]:
28 | if isinstance(value, str) and value.upper() == "NONE":
29 | value = None
30 | if value is None:
31 | return None
32 | level = logging_str_to_level(value)
33 | logging.getLogger().setLevel(level)
34 | return value
35 |
36 | # Computed fields
37 | @computed_field # type: ignore[prop-decorator]
38 | @property
39 | def root_level(self) -> str:
40 | """Root logger logging level."""
41 | level = logging.getLogger().getEffectiveLevel()
42 | level_name = logging.getLevelName(level)
43 | return level_name
44 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/data/default.config.json:
--------------------------------------------------------------------------------
1 | {
2 | }
3 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/data/load_profiles.npz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/data/load_profiles.npz
--------------------------------------------------------------------------------
/src/akkudoktoreos/data/regular_grid_interpolator.pkl:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/data/regular_grid_interpolator.pkl
--------------------------------------------------------------------------------
/src/akkudoktoreos/devices/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/devices/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/devices/devices.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from akkudoktoreos.core.coreabc import SingletonMixin
4 | from akkudoktoreos.core.logging import get_logger
5 | from akkudoktoreos.devices.battery import Battery
6 | from akkudoktoreos.devices.devicesabc import DevicesBase
7 | from akkudoktoreos.devices.generic import HomeAppliance
8 | from akkudoktoreos.devices.inverter import Inverter
9 | from akkudoktoreos.devices.settings import DevicesCommonSettings
10 |
11 | logger = get_logger(__name__)
12 |
13 |
14 | class Devices(SingletonMixin, DevicesBase):
15 | def __init__(self, settings: Optional[DevicesCommonSettings] = None):
16 | if hasattr(self, "_initialized"):
17 | return
18 | super().__init__()
19 | if settings is None:
20 | settings = self.config.devices
21 | if settings is None:
22 | return
23 |
24 | # initialize devices
25 | if settings.batteries is not None:
26 | for battery_params in settings.batteries:
27 | self.add_device(Battery(battery_params))
28 | if settings.inverters is not None:
29 | for inverter_params in settings.inverters:
30 | self.add_device(Inverter(inverter_params))
31 | if settings.home_appliances is not None:
32 | for home_appliance_params in settings.home_appliances:
33 | self.add_device(HomeAppliance(home_appliance_params))
34 |
35 | self.post_setup()
36 |
37 | def post_setup(self) -> None:
38 | for device in self.devices.values():
39 | device.post_setup()
40 |
41 |
42 | # Initialize the Devices simulation, it is a singleton.
43 | devices: Optional[Devices] = None
44 |
45 |
46 | def get_devices() -> Devices:
47 | global devices
48 | # Fix circular import at runtime
49 | if devices is None:
50 | devices = Devices()
51 | """Gets the EOS Devices simulation."""
52 | return devices
53 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/devices/generic.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | import numpy as np
4 | from pydantic import Field
5 |
6 | from akkudoktoreos.core.logging import get_logger
7 | from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
8 |
9 | logger = get_logger(__name__)
10 |
11 |
12 | class HomeApplianceParameters(DeviceParameters):
13 | """Home Appliance Device Simulation Configuration."""
14 |
15 | device_id: str = Field(description="ID of home appliance", examples=["dishwasher"])
16 | consumption_wh: int = Field(
17 | gt=0,
18 | description="An integer representing the energy consumption of a household device in watt-hours.",
19 | examples=[2000],
20 | )
21 | duration_h: int = Field(
22 | gt=0,
23 | description="An integer representing the usage duration of a household device in hours.",
24 | examples=[3],
25 | )
26 |
27 |
28 | class HomeAppliance(DeviceBase):
29 | def __init__(
30 | self,
31 | parameters: Optional[HomeApplianceParameters] = None,
32 | ):
33 | self.parameters: Optional[HomeApplianceParameters] = None
34 | super().__init__(parameters)
35 |
36 | def _setup(self) -> None:
37 | if self.parameters is None:
38 | raise ValueError(f"Parameters not set: {self.parameters}")
39 | self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros
40 | self.duration_h = self.parameters.duration_h
41 | self.consumption_wh = self.parameters.consumption_wh
42 |
43 | def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None:
44 | """Sets the start time of the device and generates the corresponding load curve.
45 |
46 | :param start_hour: The hour at which the device should start.
47 | """
48 | self.reset_load_curve()
49 | # Check if the duration of use is within the available time frame
50 | if start_hour + self.duration_h > self.hours:
51 | raise ValueError("The duration of use exceeds the available time frame.")
52 | if start_hour < global_start_hour:
53 | raise ValueError("The start time is earlier than the available time frame.")
54 |
55 | # Calculate power per hour based on total consumption and duration
56 | power_per_hour = self.consumption_wh / self.duration_h # Convert to watt-hours
57 |
58 | # Set the power for the duration of use in the load curve array
59 | self.load_curve[start_hour : start_hour + self.duration_h] = power_per_hour
60 |
61 | def reset_load_curve(self) -> None:
62 | """Resets the load curve."""
63 | self.load_curve = np.zeros(self.hours)
64 |
65 | def get_load_curve(self) -> np.ndarray:
66 | """Returns the current load curve."""
67 | return self.load_curve
68 |
69 | def get_load_for_hour(self, hour: int) -> float:
70 | """Returns the load for a specific hour.
71 |
72 | :param hour: The hour for which the load is queried.
73 | :return: The load in watts for the specified hour.
74 | """
75 | if hour < 0 or hour >= self.hours:
76 | raise ValueError("The specified hour is outside the available time frame.")
77 |
78 | return self.load_curve[hour]
79 |
80 | def get_latest_starting_point(self) -> int:
81 | """Returns the latest possible start time at which the device can still run completely."""
82 | return self.hours - self.duration_h
83 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/devices/inverter.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import Field
4 |
5 | from akkudoktoreos.core.logging import get_logger
6 | from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
7 | from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
8 |
9 | logger = get_logger(__name__)
10 |
11 |
12 | class InverterParameters(DeviceParameters):
13 | """Inverter Device Simulation Configuration."""
14 |
15 | device_id: str = Field(description="ID of inverter", examples=["inverter1"])
16 | max_power_wh: float = Field(gt=0, examples=[10000])
17 | battery_id: Optional[str] = Field(
18 | default=None, description="ID of battery", examples=[None, "battery1"]
19 | )
20 |
21 |
22 | class Inverter(DeviceBase):
23 | def __init__(
24 | self,
25 | parameters: Optional[InverterParameters] = None,
26 | ):
27 | self.parameters: Optional[InverterParameters] = None
28 | super().__init__(parameters)
29 |
30 | def _setup(self) -> None:
31 | if self.parameters is None:
32 | raise ValueError(f"Parameters not set: {self.parameters}")
33 | if self.parameters.battery_id is None:
34 | # For the moment raise exception
35 | # TODO: Make battery configurable by config
36 | error_msg = "Battery for PV inverter is mandatory."
37 | logger.error(error_msg)
38 | raise NotImplementedError(error_msg)
39 | self.self_consumption_predictor = get_eos_load_interpolator()
40 | self.max_power_wh = (
41 | self.parameters.max_power_wh
42 | ) # Maximum power that the inverter can handle
43 |
44 | def _post_setup(self) -> None:
45 | if self.parameters is None:
46 | raise ValueError(f"Parameters not set: {self.parameters}")
47 | self.battery = self.devices.get_device_by_id(self.parameters.battery_id)
48 |
49 | def process_energy(
50 | self, generation: float, consumption: float, hour: int
51 | ) -> tuple[float, float, float, float]:
52 | losses = 0.0
53 | grid_export = 0.0
54 | grid_import = 0.0
55 | self_consumption = 0.0
56 |
57 | if generation >= consumption:
58 | if consumption > self.max_power_wh:
59 | # If consumption exceeds maximum inverter power
60 | losses += generation - self.max_power_wh
61 | remaining_power = self.max_power_wh - consumption
62 | grid_import = -remaining_power # Negative indicates feeding into the grid
63 | self_consumption = self.max_power_wh
64 | else:
65 | scr = self.self_consumption_predictor.calculate_self_consumption(
66 | consumption, generation
67 | )
68 |
69 | # Remaining power after consumption
70 | remaining_power = (generation - consumption) * scr # EVQ
71 | # Remaining load Self Consumption not perfect
72 | remaining_load_evq = (generation - consumption) * (1.0 - scr)
73 |
74 | if remaining_load_evq > 0:
75 | # Akku muss den Restverbrauch decken
76 | from_battery, discharge_losses = self.battery.discharge_energy(
77 | remaining_load_evq, hour
78 | )
79 | remaining_load_evq -= from_battery # Restverbrauch nach Akkuentladung
80 | losses += discharge_losses
81 |
82 | # Wenn der Akku den Restverbrauch nicht vollständig decken kann, wird der Rest ins Netz gezogen
83 | if remaining_load_evq > 0:
84 | grid_import += remaining_load_evq
85 | remaining_load_evq = 0
86 | else:
87 | from_battery = 0.0
88 |
89 | if remaining_power > 0:
90 | # Load battery with excess energy
91 | charged_energie, charge_losses = self.battery.charge_energy(
92 | remaining_power, hour
93 | )
94 | remaining_surplus = remaining_power - (charged_energie + charge_losses)
95 |
96 | # Feed-in to the grid based on remaining capacity
97 | if remaining_surplus > self.max_power_wh - consumption:
98 | grid_export = self.max_power_wh - consumption
99 | losses += remaining_surplus - grid_export
100 | else:
101 | grid_export = remaining_surplus
102 |
103 | losses += charge_losses
104 | self_consumption = (
105 | consumption + from_battery
106 | ) # Self-consumption is equal to the load
107 |
108 | else:
109 | # Case 2: Insufficient generation, cover shortfall
110 | shortfall = consumption - generation
111 | available_ac_power = max(self.max_power_wh - generation, 0)
112 |
113 | # Discharge battery to cover shortfall, if possible
114 | battery_discharge, discharge_losses = self.battery.discharge_energy(
115 | min(shortfall, available_ac_power), hour
116 | )
117 | losses += discharge_losses
118 |
119 | # Draw remaining required power from the grid (discharge_losses are already substraved in the battery)
120 | grid_import = shortfall - battery_discharge
121 | self_consumption = generation + battery_discharge
122 |
123 | return grid_export, grid_import, losses, self_consumption
124 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/devices/settings.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import Field
4 |
5 | from akkudoktoreos.config.configabc import SettingsBaseModel
6 | from akkudoktoreos.core.logging import get_logger
7 | from akkudoktoreos.devices.battery import BaseBatteryParameters
8 | from akkudoktoreos.devices.generic import HomeApplianceParameters
9 | from akkudoktoreos.devices.inverter import InverterParameters
10 |
11 | logger = get_logger(__name__)
12 |
13 |
14 | class DevicesCommonSettings(SettingsBaseModel):
15 | """Base configuration for devices simulation settings."""
16 |
17 | batteries: Optional[list[BaseBatteryParameters]] = Field(
18 | default=None,
19 | description="List of battery/ev devices",
20 | examples=[[{"device_id": "battery1", "capacity_wh": 8000}]],
21 | )
22 | inverters: Optional[list[InverterParameters]] = Field(
23 | default=None, description="List of inverters", examples=[[]]
24 | )
25 | home_appliances: Optional[list[HomeApplianceParameters]] = Field(
26 | default=None, description="List of home appliances", examples=[[]]
27 | )
28 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/measurement/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/measurement/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/optimization/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/optimization/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/optimization/optimization.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional
2 |
3 | from pydantic import Field
4 |
5 | from akkudoktoreos.config.configabc import SettingsBaseModel
6 | from akkudoktoreos.core.logging import get_logger
7 |
8 | logger = get_logger(__name__)
9 |
10 |
11 | class OptimizationCommonSettings(SettingsBaseModel):
12 | """General Optimization Configuration.
13 |
14 | Attributes:
15 | hours (int): Number of hours for optimizations.
16 | """
17 |
18 | hours: Optional[int] = Field(
19 | default=48, ge=0, description="Number of hours into the future for optimizations."
20 | )
21 |
22 | penalty: Optional[int] = Field(default=10, description="Penalty factor used in optimization.")
23 |
24 | ev_available_charge_rates_percent: Optional[List[float]] = Field(
25 | default=[
26 | 0.0,
27 | 6.0 / 16.0,
28 | # 7.0 / 16.0,
29 | 8.0 / 16.0,
30 | # 9.0 / 16.0,
31 | 10.0 / 16.0,
32 | # 11.0 / 16.0,
33 | 12.0 / 16.0,
34 | # 13.0 / 16.0,
35 | 14.0 / 16.0,
36 | # 15.0 / 16.0,
37 | 1.0,
38 | ],
39 | description="Charge rates available for the EV in percent of maximum charge.",
40 | )
41 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/optimization/optimizationabc.py:
--------------------------------------------------------------------------------
1 | """Abstract and base classes for optimization."""
2 |
3 | from pydantic import ConfigDict
4 |
5 | from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin
6 | from akkudoktoreos.core.logging import get_logger
7 | from akkudoktoreos.core.pydantic import PydanticBaseModel
8 |
9 | logger = get_logger(__name__)
10 |
11 |
12 | class OptimizationBase(ConfigMixin, PredictionMixin, PydanticBaseModel):
13 | """Base class for handling optimization data.
14 |
15 | Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
16 | `prediction`).
17 |
18 | Note:
19 | Validation on assignment of the Pydantic model is disabled to speed up optimization runs.
20 | """
21 |
22 | # Disable validation on assignment to speed up optimization runs.
23 | model_config = ConfigDict(
24 | validate_assignment=False,
25 | )
26 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/prediction/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/elecprice.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import Field, field_validator
4 |
5 | from akkudoktoreos.config.configabc import SettingsBaseModel
6 | from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
7 | from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
8 | from akkudoktoreos.prediction.prediction import get_prediction
9 |
10 | prediction_eos = get_prediction()
11 |
12 | # Valid elecprice providers
13 | elecprice_providers = [
14 | provider.provider_id()
15 | for provider in prediction_eos.providers
16 | if isinstance(provider, ElecPriceProvider)
17 | ]
18 |
19 |
20 | class ElecPriceCommonSettings(SettingsBaseModel):
21 | """Electricity Price Prediction Configuration."""
22 |
23 | provider: Optional[str] = Field(
24 | default=None,
25 | description="Electricity price provider id of provider to be used.",
26 | examples=["ElecPriceAkkudoktor"],
27 | )
28 | charges_kwh: Optional[float] = Field(
29 | default=None, ge=0, description="Electricity price charges (€/kWh).", examples=[0.21]
30 | )
31 |
32 | provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
33 | default=None, description="Provider settings", examples=[None]
34 | )
35 |
36 | # Validators
37 | @field_validator("provider", mode="after")
38 | @classmethod
39 | def validate_provider(cls, value: Optional[str]) -> Optional[str]:
40 | if value is None or value in elecprice_providers:
41 | return value
42 | raise ValueError(
43 | f"Provider '{value}' is not a valid electricity price provider: {elecprice_providers}."
44 | )
45 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/elecpriceabc.py:
--------------------------------------------------------------------------------
1 | """Abstract and base classes for electricity price predictions.
2 |
3 | Notes:
4 | - Ensure appropriate API keys or configurations are set up if required by external data sources.
5 | """
6 |
7 | from abc import abstractmethod
8 | from typing import List, Optional
9 |
10 | from pydantic import Field, computed_field
11 |
12 | from akkudoktoreos.core.logging import get_logger
13 | from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
14 |
15 | logger = get_logger(__name__)
16 |
17 |
18 | class ElecPriceDataRecord(PredictionRecord):
19 | """Represents a electricity price data record containing various price attributes at a specific datetime.
20 |
21 | Attributes:
22 | date_time (Optional[AwareDatetime]): The datetime of the record.
23 |
24 | """
25 |
26 | elecprice_marketprice_wh: Optional[float] = Field(
27 | None, description="Electricity market price per Wh (€/Wh)"
28 | )
29 |
30 | # Computed fields
31 | @computed_field # type: ignore[prop-decorator]
32 | @property
33 | def elecprice_marketprice_kwh(self) -> Optional[float]:
34 | """Electricity market price per kWh (€/kWh).
35 |
36 | Convenience attribute calculated from `elecprice_marketprice_wh`.
37 | """
38 | if self.elecprice_marketprice_wh is None:
39 | return None
40 | return self.elecprice_marketprice_wh * 1000.0
41 |
42 |
43 | class ElecPriceProvider(PredictionProvider):
44 | """Abstract base class for electricity price providers.
45 |
46 | WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
47 |
48 | Configuration variables:
49 | electricity price_provider (str): Prediction provider for electricity price.
50 |
51 | Attributes:
52 | hours (int, optional): The number of hours into the future for which predictions are generated.
53 | historic_hours (int, optional): The number of past hours for which historical data is retained.
54 | latitude (float, optional): The latitude in degrees, must be within -90 to 90.
55 | longitude (float, optional): The longitude in degrees, must be within -180 to 180.
56 | start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
57 | end_datetime (datetime, computed): The datetime representing the end of the prediction range,
58 | calculated based on `start_datetime` and `hours`.
59 | keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
60 | based on `start_datetime` and `historic_hours`.
61 | """
62 |
63 | # overload
64 | records: List[ElecPriceDataRecord] = Field(
65 | default_factory=list, description="List of ElecPriceDataRecord records"
66 | )
67 |
68 | @classmethod
69 | @abstractmethod
70 | def provider_id(cls) -> str:
71 | return "ElecPriceProvider"
72 |
73 | def enabled(self) -> bool:
74 | return self.provider_id() == self.config.elecprice.provider
75 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/elecpriceimport.py:
--------------------------------------------------------------------------------
1 | """Retrieves elecprice forecast data from an import file.
2 |
3 | This module provides classes and mappings to manage elecprice data obtained from
4 | an import file, including support for various elecprice attributes such as temperature,
5 | humidity, cloud cover, and solar irradiance. The data is mapped to the `ElecPriceDataRecord`
6 | format, enabling consistent access to forecasted and historical elecprice attributes.
7 | """
8 |
9 | from pathlib import Path
10 | from typing import Optional, Union
11 |
12 | from pydantic import Field, field_validator
13 |
14 | from akkudoktoreos.config.configabc import SettingsBaseModel
15 | from akkudoktoreos.core.logging import get_logger
16 | from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
17 | from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
18 |
19 | logger = get_logger(__name__)
20 |
21 |
22 | class ElecPriceImportCommonSettings(SettingsBaseModel):
23 | """Common settings for elecprice data import from file or JSON String."""
24 |
25 | import_file_path: Optional[Union[str, Path]] = Field(
26 | default=None,
27 | description="Path to the file to import elecprice data from.",
28 | examples=[None, "/path/to/prices.json"],
29 | )
30 |
31 | import_json: Optional[str] = Field(
32 | default=None,
33 | description="JSON string, dictionary of electricity price forecast value lists.",
34 | examples=['{"elecprice_marketprice_wh": [0.0003384, 0.0003318, 0.0003284]}'],
35 | )
36 |
37 | # Validators
38 | @field_validator("import_file_path", mode="after")
39 | @classmethod
40 | def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
41 | if value is None:
42 | return None
43 | if isinstance(value, str):
44 | value = Path(value)
45 | """Ensure file is available."""
46 | value.resolve()
47 | if not value.is_file():
48 | raise ValueError(f"Import file path '{value}' is not a file.")
49 | return value
50 |
51 |
52 | class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
53 | """Fetch PV forecast data from import file or JSON string.
54 |
55 | ElecPriceImport is a singleton-based class that retrieves elecprice forecast data
56 | from a file or JSON string and maps it to `ElecPriceDataRecord` fields. It manages the forecast
57 | over a range of hours into the future and retains historical data.
58 | """
59 |
60 | @classmethod
61 | def provider_id(cls) -> str:
62 | """Return the unique identifier for the ElecPriceImport provider."""
63 | return "ElecPriceImport"
64 |
65 | def _update_data(self, force_update: Optional[bool] = False) -> None:
66 | if self.config.elecprice.provider_settings is None:
67 | logger.debug(f"{self.provider_id()} data update without provider settings.")
68 | return
69 | if self.config.elecprice.provider_settings.import_file_path:
70 | self.import_from_file(
71 | self.config.elecprice.provider_settings.import_file_path,
72 | key_prefix="elecprice",
73 | )
74 | if self.config.elecprice.provider_settings.import_json:
75 | self.import_from_json(
76 | self.config.elecprice.provider_settings.import_json, key_prefix="elecprice"
77 | )
78 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/interpolator.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import pickle
3 | from functools import lru_cache
4 | from pathlib import Path
5 |
6 | import numpy as np
7 | from scipy.interpolate import RegularGridInterpolator
8 |
9 | from akkudoktoreos.core.coreabc import SingletonMixin
10 |
11 |
12 | class SelfConsumptionProbabilityInterpolator:
13 | def __init__(self, filepath: str | Path):
14 | self.filepath = filepath
15 | # Load the RegularGridInterpolator
16 | with open(self.filepath, "rb") as file:
17 | self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301
18 |
19 | @lru_cache(maxsize=128)
20 | def generate_points(
21 | self, load_1h_power: float, pv_power: float
22 | ) -> tuple[np.ndarray, np.ndarray]:
23 | """Generate the grid points for interpolation."""
24 | partial_loads = np.arange(0, pv_power + 50, 50)
25 | points = np.array([np.full_like(partial_loads, load_1h_power), partial_loads]).T
26 | return points, partial_loads
27 |
28 | def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
29 | points, partial_loads = self.generate_points(load_1h_power, pv_power)
30 | probabilities = self.interpolator(points)
31 | return probabilities.sum()
32 |
33 | # def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
34 | # """Calculate the PV self-consumption rate using RegularGridInterpolator.
35 |
36 | # Args:
37 | # - last_1h_power: 1h power levels (W).
38 | # - pv_power: Current PV power output (W).
39 |
40 | # Returns:
41 | # - Self-consumption rate as a float.
42 | # """
43 | # # Generate the range of partial loads (0 to last_1h_power)
44 | # partial_loads = np.arange(0, pv_power + 50, 50)
45 |
46 | # # Get probabilities for all partial loads
47 | # points = np.array([np.full_like(partial_loads, load_1h_power), partial_loads]).T
48 | # if self.interpolator == None:
49 | # return -1.0
50 | # probabilities = self.interpolator(points)
51 | # self_consumption_rate = probabilities.sum()
52 |
53 | # # probabilities = probabilities / (np.sum(probabilities)) # / (pv_power / 3450))
54 | # # # for i, w in enumerate(partial_loads):
55 | # # # print(w, ": ", probabilities[i])
56 | # # print(probabilities.sum())
57 |
58 | # # # Ensure probabilities are within [0, 1]
59 | # # probabilities = np.clip(probabilities, 0, 1)
60 |
61 | # # # Mask: Only include probabilities where the load is <= PV power
62 | # # mask = partial_loads <= pv_power
63 |
64 | # # # Calculate the cumulative probability for covered loads
65 | # # self_consumption_rate = np.sum(probabilities[mask]) / np.sum(probabilities)
66 | # # print(self_consumption_rate)
67 | # # sys.exit()
68 |
69 | # return self_consumption_rate
70 |
71 |
72 | class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin):
73 | def __init__(self) -> None:
74 | if hasattr(self, "_initialized"):
75 | return
76 | filename = Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
77 | super().__init__(filename)
78 |
79 |
80 | # Initialize the Energy Management System, it is a singleton.
81 | eos_load_interpolator = EOSLoadInterpolator()
82 |
83 |
84 | def get_eos_load_interpolator() -> EOSLoadInterpolator:
85 | return eos_load_interpolator
86 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/load.py:
--------------------------------------------------------------------------------
1 | """Load forecast module for load predictions."""
2 |
3 | from typing import Optional, Union
4 |
5 | from pydantic import Field, field_validator
6 |
7 | from akkudoktoreos.config.configabc import SettingsBaseModel
8 | from akkudoktoreos.core.logging import get_logger
9 | from akkudoktoreos.prediction.loadabc import LoadProvider
10 | from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
11 | from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
12 | from akkudoktoreos.prediction.prediction import get_prediction
13 |
14 | logger = get_logger(__name__)
15 | prediction_eos = get_prediction()
16 |
17 | # Valid load providers
18 | load_providers = [
19 | provider.provider_id()
20 | for provider in prediction_eos.providers
21 | if isinstance(provider, LoadProvider)
22 | ]
23 |
24 |
25 | class LoadCommonSettings(SettingsBaseModel):
26 | """Load Prediction Configuration."""
27 |
28 | provider: Optional[str] = Field(
29 | default=None,
30 | description="Load provider id of provider to be used.",
31 | examples=["LoadAkkudoktor"],
32 | )
33 |
34 | provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
35 | Field(default=None, description="Provider settings", examples=[None])
36 | )
37 |
38 | # Validators
39 | @field_validator("provider", mode="after")
40 | @classmethod
41 | def validate_provider(cls, value: Optional[str]) -> Optional[str]:
42 | if value is None or value in load_providers:
43 | return value
44 | raise ValueError(f"Provider '{value}' is not a valid load provider: {load_providers}.")
45 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/loadabc.py:
--------------------------------------------------------------------------------
1 | """Abstract and base classes for load predictions.
2 |
3 | Notes:
4 | - Ensure appropriate API keys or configurations are set up if required by external data sources.
5 | """
6 |
7 | from abc import abstractmethod
8 | from typing import List, Optional
9 |
10 | from pydantic import Field
11 |
12 | from akkudoktoreos.core.logging import get_logger
13 | from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
14 |
15 | logger = get_logger(__name__)
16 |
17 |
18 | class LoadDataRecord(PredictionRecord):
19 | """Represents a load data record containing various load attributes at a specific datetime."""
20 |
21 | load_mean: Optional[float] = Field(default=None, description="Predicted load mean value (W).")
22 | load_std: Optional[float] = Field(
23 | default=None, description="Predicted load standard deviation (W)."
24 | )
25 | load_mean_adjusted: Optional[float] = Field(
26 | default=None, description="Predicted load mean value adjusted by load measurement (W)."
27 | )
28 |
29 |
30 | class LoadProvider(PredictionProvider):
31 | """Abstract base class for load providers.
32 |
33 | LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
34 |
35 | Configuration variables:
36 | provider (str): Prediction provider for load.
37 |
38 | Attributes:
39 | hours (int, optional): The number of hours into the future for which predictions are generated.
40 | historic_hours (int, optional): The number of past hours for which historical data is retained.
41 | latitude (float, optional): The latitude in degrees, must be within -90 to 90.
42 | longitude (float, optional): The longitude in degrees, must be within -180 to 180.
43 | start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
44 | end_datetime (datetime, computed): The datetime representing the end of the prediction range,
45 | calculated based on `start_datetime` and `hours`.
46 | keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
47 | based on `start_datetime` and `historic_hours`.
48 | """
49 |
50 | # overload
51 | records: List[LoadDataRecord] = Field(
52 | default_factory=list, description="List of LoadDataRecord records"
53 | )
54 |
55 | @classmethod
56 | @abstractmethod
57 | def provider_id(cls) -> str:
58 | return "LoadProvider"
59 |
60 | def enabled(self) -> bool:
61 | return self.provider_id() == self.config.load.provider
62 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/loadimport.py:
--------------------------------------------------------------------------------
1 | """Retrieves load forecast data from an import file.
2 |
3 | This module provides classes and mappings to manage load data obtained from
4 | an import file, including support for various load attributes such as temperature,
5 | humidity, cloud cover, and solar irradiance. The data is mapped to the `LoadDataRecord`
6 | format, enabling consistent access to forecasted and historical load attributes.
7 | """
8 |
9 | from pathlib import Path
10 | from typing import Optional, Union
11 |
12 | from pydantic import Field, field_validator
13 |
14 | from akkudoktoreos.config.configabc import SettingsBaseModel
15 | from akkudoktoreos.core.logging import get_logger
16 | from akkudoktoreos.prediction.loadabc import LoadProvider
17 | from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
18 |
19 | logger = get_logger(__name__)
20 |
21 |
22 | class LoadImportCommonSettings(SettingsBaseModel):
23 | """Common settings for load data import from file or JSON string."""
24 |
25 | import_file_path: Optional[Union[str, Path]] = Field(
26 | default=None,
27 | description="Path to the file to import load data from.",
28 | examples=[None, "/path/to/yearly_load.json"],
29 | )
30 | import_json: Optional[str] = Field(
31 | default=None,
32 | description="JSON string, dictionary of load forecast value lists.",
33 | examples=['{"load0_mean": [676.71, 876.19, 527.13]}'],
34 | )
35 |
36 | # Validators
37 | @field_validator("import_file_path", mode="after")
38 | @classmethod
39 | def validate_loadimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
40 | if value is None:
41 | return None
42 | if isinstance(value, str):
43 | value = Path(value)
44 | """Ensure file is available."""
45 | value.resolve()
46 | if not value.is_file():
47 | raise ValueError(f"Import file path '{value}' is not a file.")
48 | return value
49 |
50 |
51 | class LoadImport(LoadProvider, PredictionImportProvider):
52 | """Fetch Load data from import file or JSON string.
53 |
54 | LoadImport is a singleton-based class that retrieves load forecast data
55 | from a file or JSON string and maps it to `LoadDataRecord` fields. It manages the forecast
56 | over a range of hours into the future and retains historical data.
57 | """
58 |
59 | @classmethod
60 | def provider_id(cls) -> str:
61 | """Return the unique identifier for the LoadImport provider."""
62 | return "LoadImport"
63 |
64 | def _update_data(self, force_update: Optional[bool] = False) -> None:
65 | if self.config.load.provider_settings is None:
66 | logger.debug(f"{self.provider_id()} data update without provider settings.")
67 | return
68 | if self.config.load.provider_settings.import_file_path:
69 | self.import_from_file(self.config.provider_settings.import_file_path, key_prefix="load")
70 | if self.config.load.provider_settings.import_json:
71 | self.import_from_json(self.config.load.provider_settings.import_json, key_prefix="load")
72 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/prediction.py:
--------------------------------------------------------------------------------
1 | """Prediction module for weather and photovoltaic forecasts.
2 |
3 | This module provides a `Prediction` class to manage and update a sequence of
4 | prediction providers. The `Prediction` class is a subclass of `PredictionContainer`
5 | and is initialized with a set of forecast providers, such as `WeatherBrightSky`,
6 | `WeatherClearOutside`, and `PVForecastAkkudoktor`.
7 |
8 | Usage:
9 | Instantiate the `Prediction` class with the required providers, maintaining
10 | the necessary order. Then call the `update` method to refresh forecasts from
11 | all providers in sequence.
12 |
13 | Example:
14 | # Create singleton prediction instance with prediction providers
15 | from akkudoktoreos.prediction.prediction import prediction
16 |
17 | prediction.update_data()
18 | print("Prediction:", prediction)
19 |
20 | Classes:
21 | Prediction: Manages a list of forecast providers to fetch and update predictions.
22 |
23 | Attributes:
24 | pvforecast_akkudoktor (PVForecastAkkudoktor): Forecast provider for photovoltaic data.
25 | weather_brightsky (WeatherBrightSky): Weather forecast provider using BrightSky.
26 | weather_clearoutside (WeatherClearOutside): Weather forecast provider using ClearOutside.
27 | """
28 |
29 | from typing import List, Optional, Union
30 |
31 | from pydantic import Field
32 |
33 | from akkudoktoreos.config.configabc import SettingsBaseModel
34 | from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
35 | from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
36 | from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
37 | from akkudoktoreos.prediction.loadimport import LoadImport
38 | from akkudoktoreos.prediction.predictionabc import PredictionContainer
39 | from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
40 | from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
41 | from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
42 | from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
43 | from akkudoktoreos.prediction.weatherimport import WeatherImport
44 |
45 |
46 | class PredictionCommonSettings(SettingsBaseModel):
47 | """General Prediction Configuration.
48 |
49 | This class provides configuration for prediction settings, allowing users to specify
50 | parameters such as the forecast duration (in hours).
51 | Validators ensure each parameter is within a specified range.
52 |
53 | Attributes:
54 | hours (Optional[int]): Number of hours into the future for predictions.
55 | Must be non-negative.
56 | historic_hours (Optional[int]): Number of hours into the past for historical data.
57 | Must be non-negative.
58 |
59 | Validators:
60 | validate_hours (int): Ensures `hours` is a non-negative integer.
61 | validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.
62 | """
63 |
64 | hours: Optional[int] = Field(
65 | default=48, ge=0, description="Number of hours into the future for predictions"
66 | )
67 | historic_hours: Optional[int] = Field(
68 | default=48,
69 | ge=0,
70 | description="Number of hours into the past for historical predictions data",
71 | )
72 |
73 |
74 | class Prediction(PredictionContainer):
75 | """Prediction container to manage multiple prediction providers.
76 |
77 | Attributes:
78 | providers (List[Union[PVForecastAkkudoktor, WeatherBrightSky, WeatherClearOutside]]):
79 | List of forecast provider instances, in the order they should be updated.
80 | Providers may depend on updates from others.
81 | """
82 |
83 | providers: List[
84 | Union[
85 | ElecPriceAkkudoktor,
86 | ElecPriceImport,
87 | LoadAkkudoktor,
88 | LoadImport,
89 | PVForecastAkkudoktor,
90 | PVForecastImport,
91 | WeatherBrightSky,
92 | WeatherClearOutside,
93 | WeatherImport,
94 | ]
95 | ] = Field(default_factory=list, description="List of prediction providers")
96 |
97 |
98 | # Initialize forecast providers, all are singletons.
99 | elecprice_akkudoktor = ElecPriceAkkudoktor()
100 | elecprice_import = ElecPriceImport()
101 | load_akkudoktor = LoadAkkudoktor()
102 | load_import = LoadImport()
103 | pvforecast_akkudoktor = PVForecastAkkudoktor()
104 | pvforecast_import = PVForecastImport()
105 | weather_brightsky = WeatherBrightSky()
106 | weather_clearoutside = WeatherClearOutside()
107 | weather_import = WeatherImport()
108 |
109 |
110 | def get_prediction() -> Prediction:
111 | """Gets the EOS prediction data."""
112 | # Initialize Prediction instance with providers in the required order
113 | # Care for provider sequence as providers may rely on others to be updated before.
114 | prediction = Prediction(
115 | providers=[
116 | elecprice_akkudoktor,
117 | elecprice_import,
118 | load_akkudoktor,
119 | load_import,
120 | pvforecast_akkudoktor,
121 | pvforecast_import,
122 | weather_brightsky,
123 | weather_clearoutside,
124 | weather_import,
125 | ]
126 | )
127 | return prediction
128 |
129 |
130 | def main() -> None:
131 | """Main function to update and display predictions.
132 |
133 | This function initializes and updates the forecast providers in sequence
134 | according to the `Prediction` instance, then prints the updated prediction data.
135 | """
136 | prediction = get_prediction()
137 | prediction.update_data()
138 | print(f"Prediction: {prediction}")
139 |
140 |
141 | if __name__ == "__main__":
142 | main()
143 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/pvforecastabc.py:
--------------------------------------------------------------------------------
1 | """Abstract and base classes for pvforecast predictions.
2 |
3 | Notes:
4 | - Ensure appropriate API keys or configurations are set up if required by external data sources.
5 | """
6 |
7 | from abc import abstractmethod
8 | from typing import List, Optional
9 |
10 | from pydantic import Field
11 |
12 | from akkudoktoreos.core.logging import get_logger
13 | from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
14 |
15 | logger = get_logger(__name__)
16 |
17 |
18 | class PVForecastDataRecord(PredictionRecord):
19 | """Represents a pvforecast data record containing various pvforecast attributes at a specific datetime."""
20 |
21 | pvforecast_dc_power: Optional[float] = Field(default=None, description="Total DC power (W).")
22 | pvforecast_ac_power: Optional[float] = Field(default=None, description="Total AC power (W).")
23 |
24 |
25 | class PVForecastProvider(PredictionProvider):
26 | """Abstract base class for pvforecast providers.
27 |
28 | PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
29 |
30 | Configuration variables:
31 | provider (str): Prediction provider for pvforecast.
32 |
33 | Attributes:
34 | hours (int, optional): The number of hours into the future for which predictions are generated.
35 | historic_hours (int, optional): The number of past hours for which historical data is retained.
36 | latitude (float, optional): The latitude in degrees, must be within -90 to 90.
37 | longitude (float, optional): The longitude in degrees, must be within -180 to 180.
38 | start_datetime (datetime, optional): The starting datetime for predictions (inlcusive), defaults to the current datetime if unspecified.
39 | end_datetime (datetime, computed): The datetime representing the end of the prediction range (exclusive),
40 | calculated based on `start_datetime` and `hours`.
41 | keep_datetime (datetime, computed): The earliest datetime for retaining historical data (inclusive), calculated
42 | based on `start_datetime` and `historic_hours`.
43 | """
44 |
45 | # overload
46 | records: List[PVForecastDataRecord] = Field(
47 | default_factory=list, description="List of PVForecastDataRecord records"
48 | )
49 |
50 | @classmethod
51 | @abstractmethod
52 | def provider_id(cls) -> str:
53 | return "PVForecastProvider"
54 |
55 | def enabled(self) -> bool:
56 | logger.debug(
57 | f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.provider}"
58 | )
59 | return self.provider_id() == self.config.pvforecast.provider
60 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/pvforecastimport.py:
--------------------------------------------------------------------------------
1 | """Retrieves pvforecast forecast data from an import file.
2 |
3 | This module provides classes and mappings to manage pvforecast data obtained from
4 | an import file, including support for various pvforecast attributes such as temperature,
5 | humidity, cloud cover, and solar irradiance. The data is mapped to the `PVForecastDataRecord`
6 | format, enabling consistent access to forecasted and historical pvforecast attributes.
7 | """
8 |
9 | from pathlib import Path
10 | from typing import Optional, Union
11 |
12 | from pydantic import Field, field_validator
13 |
14 | from akkudoktoreos.config.configabc import SettingsBaseModel
15 | from akkudoktoreos.core.logging import get_logger
16 | from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
17 | from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
18 |
19 | logger = get_logger(__name__)
20 |
21 |
22 | class PVForecastImportCommonSettings(SettingsBaseModel):
23 | """Common settings for pvforecast data import from file or JSON string."""
24 |
25 | import_file_path: Optional[Union[str, Path]] = Field(
26 | default=None,
27 | description="Path to the file to import PV forecast data from.",
28 | examples=[None, "/path/to/pvforecast.json"],
29 | )
30 |
31 | import_json: Optional[str] = Field(
32 | default=None,
33 | description="JSON string, dictionary of PV forecast value lists.",
34 | examples=['{"pvforecast_ac_power": [0, 8.05, 352.91]}'],
35 | )
36 |
37 | # Validators
38 | @field_validator("import_file_path", mode="after")
39 | @classmethod
40 | def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
41 | if value is None:
42 | return None
43 | if isinstance(value, str):
44 | value = Path(value)
45 | """Ensure file is available."""
46 | value.resolve()
47 | if not value.is_file():
48 | raise ValueError(f"Import file path '{value}' is not a file.")
49 | return value
50 |
51 |
52 | class PVForecastImport(PVForecastProvider, PredictionImportProvider):
53 | """Fetch PV forecast data from import file or JSON string.
54 |
55 | PVForecastImport is a singleton-based class that retrieves pvforecast forecast data
56 | from a file or JSON string and maps it to `PVForecastDataRecord` fields. It manages the forecast
57 | over a range of hours into the future and retains historical data.
58 | """
59 |
60 | @classmethod
61 | def provider_id(cls) -> str:
62 | """Return the unique identifier for the PVForecastImport provider."""
63 | return "PVForecastImport"
64 |
65 | def _update_data(self, force_update: Optional[bool] = False) -> None:
66 | if self.config.pvforecast.provider_settings is None:
67 | logger.debug(f"{self.provider_id()} data update without provider settings.")
68 | return
69 | if self.config.pvforecast.provider_settings.import_file_path is not None:
70 | self.import_from_file(
71 | self.config.pvforecast.provider_settings.import_file_path,
72 | key_prefix="pvforecast",
73 | )
74 | if self.config.pvforecast.provider_settings.import_json is not None:
75 | self.import_from_json(
76 | self.config.pvforecast.provider_settings.import_json,
77 | key_prefix="pvforecast",
78 | )
79 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/weather.py:
--------------------------------------------------------------------------------
1 | """Weather forecast module for weather predictions."""
2 |
3 | from typing import Optional
4 |
5 | from pydantic import Field, field_validator
6 |
7 | from akkudoktoreos.config.configabc import SettingsBaseModel
8 | from akkudoktoreos.prediction.prediction import get_prediction
9 | from akkudoktoreos.prediction.weatherabc import WeatherProvider
10 | from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
11 |
12 | prediction_eos = get_prediction()
13 |
14 | # Valid weather providers
15 | weather_providers = [
16 | provider.provider_id()
17 | for provider in prediction_eos.providers
18 | if isinstance(provider, WeatherProvider)
19 | ]
20 |
21 |
22 | class WeatherCommonSettings(SettingsBaseModel):
23 | """Weather Forecast Configuration."""
24 |
25 | provider: Optional[str] = Field(
26 | default=None,
27 | description="Weather provider id of provider to be used.",
28 | examples=["WeatherImport"],
29 | )
30 |
31 | provider_settings: Optional[WeatherImportCommonSettings] = Field(
32 | default=None, description="Provider settings", examples=[None]
33 | )
34 |
35 | # Validators
36 | @field_validator("provider", mode="after")
37 | @classmethod
38 | def validate_provider(cls, value: Optional[str]) -> Optional[str]:
39 | if value is None or value in weather_providers:
40 | return value
41 | raise ValueError(
42 | f"Provider '{value}' is not a valid weather provider: {weather_providers}."
43 | )
44 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/prediction/weatherimport.py:
--------------------------------------------------------------------------------
1 | """Retrieves weather forecast data from an import file.
2 |
3 | This module provides classes and mappings to manage weather data obtained from
4 | an import file, including support for various weather attributes such as temperature,
5 | humidity, cloud cover, and solar irradiance. The data is mapped to the `WeatherDataRecord`
6 | format, enabling consistent access to forecasted and historical weather attributes.
7 | """
8 |
9 | from pathlib import Path
10 | from typing import Optional, Union
11 |
12 | from pydantic import Field, field_validator
13 |
14 | from akkudoktoreos.config.configabc import SettingsBaseModel
15 | from akkudoktoreos.core.logging import get_logger
16 | from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
17 | from akkudoktoreos.prediction.weatherabc import WeatherProvider
18 |
19 | logger = get_logger(__name__)
20 |
21 |
22 | class WeatherImportCommonSettings(SettingsBaseModel):
23 | """Common settings for weather data import from file or JSON string."""
24 |
25 | import_file_path: Optional[Union[str, Path]] = Field(
26 | default=None,
27 | description="Path to the file to import weather data from.",
28 | examples=[None, "/path/to/weather_data.json"],
29 | )
30 |
31 | import_json: Optional[str] = Field(
32 | default=None,
33 | description="JSON string, dictionary of weather forecast value lists.",
34 | examples=['{"weather_temp_air": [18.3, 17.8, 16.9]}'],
35 | )
36 |
37 | # Validators
38 | @field_validator("import_file_path", mode="after")
39 | @classmethod
40 | def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
41 | if value is None:
42 | return None
43 | if isinstance(value, str):
44 | value = Path(value)
45 | """Ensure file is available."""
46 | value.resolve()
47 | if not value.is_file():
48 | raise ValueError(f"Import file path '{value}' is not a file.")
49 | return value
50 |
51 |
52 | class WeatherImport(WeatherProvider, PredictionImportProvider):
53 | """Fetch weather forecast data from import file or JSON string.
54 |
55 | WeatherImport is a singleton-based class that retrieves weather forecast data
56 | from a file or JSON string and maps it to `WeatherDataRecord` fields. It manages the forecast
57 | over a range of hours into the future and retains historical data.
58 | """
59 |
60 | @classmethod
61 | def provider_id(cls) -> str:
62 | """Return the unique identifier for the WeatherImport provider."""
63 | return "WeatherImport"
64 |
65 | def _update_data(self, force_update: Optional[bool] = False) -> None:
66 | if self.config.weather.provider_settings is None:
67 | logger.debug(f"{self.provider_id()} data update without provider settings.")
68 | return
69 | if self.config.weather.provider_settings.import_file_path:
70 | self.import_from_file(
71 | self.config.weather.provider_settings.import_file_path, key_prefix="weather"
72 | )
73 | if self.config.weather.provider_settings.import_json:
74 | self.import_from_json(
75 | self.config.weather.provider_settings.import_json, key_prefix="weather"
76 | )
77 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/assets/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/assets/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/assets/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/assets/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/assets/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/assets/favicon/favicon.ico
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
2 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/assets/icon.png
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/dash/assets/logo.png
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/bokeh.py:
--------------------------------------------------------------------------------
1 | # Module taken from https://github.com/koaning/fh-altair
2 | # MIT license
3 |
4 | from typing import Optional
5 |
6 | from bokeh.embed import components
7 | from bokeh.models import Plot
8 | from monsterui.franken import H4, Card, NotStr, Script
9 |
10 | BokehJS = [
11 | Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.7.0.min.js", crossorigin="anonymous"),
12 | Script(
13 | src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.7.0.min.js",
14 | crossorigin="anonymous",
15 | ),
16 | Script(
17 | src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.7.0.min.js", crossorigin="anonymous"
18 | ),
19 | Script(
20 | src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.7.0.min.js", crossorigin="anonymous"
21 | ),
22 | Script(
23 | src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.7.0.min.js",
24 | crossorigin="anonymous",
25 | ),
26 | ]
27 |
28 |
29 | def Bokeh(plot: Plot, header: Optional[str] = None) -> Card:
30 | """Converts an Bokeh plot to a FastHTML FT component."""
31 | script, div = components(plot)
32 | if header:
33 | header = H4(header, cls="mt-2")
34 | return Card(
35 | NotStr(div),
36 | NotStr(script),
37 | header=header,
38 | )
39 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/data/democonfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "elecprice": {
3 | "charges_kwh": 0.21,
4 | "provider": "ElecPriceAkkudoktor"
5 | },
6 | "general": {
7 | "latitude": 52.5,
8 | "longitude": 13.4
9 | },
10 | "prediction": {
11 | "historic_hours": 48,
12 | "hours": 48
13 | },
14 | "load": {
15 | "provider": "LoadAkkudoktor",
16 | "provider_settings": {
17 | "loadakkudoktor_year_energy": 20000
18 | }
19 | },
20 | "optimization": {
21 | "hours": 48
22 | },
23 | "pvforecast": {
24 | "planes": [
25 | {
26 | "peakpower": 5.0,
27 | "surface_azimuth": 170,
28 | "surface_tilt": 7,
29 | "userhorizon": [
30 | 20,
31 | 27,
32 | 22,
33 | 20
34 | ],
35 | "inverter_paco": 10000
36 | },
37 | {
38 | "peakpower": 4.8,
39 | "surface_azimuth": 90,
40 | "surface_tilt": 7,
41 | "userhorizon": [
42 | 30,
43 | 30,
44 | 30,
45 | 50
46 | ],
47 | "inverter_paco": 10000
48 | },
49 | {
50 | "peakpower": 1.4,
51 | "surface_azimuth": 140,
52 | "surface_tilt": 60,
53 | "userhorizon": [
54 | 60,
55 | 30,
56 | 0,
57 | 30
58 | ],
59 | "inverter_paco": 2000
60 | },
61 | {
62 | "peakpower": 1.6,
63 | "surface_azimuth": 185,
64 | "surface_tilt": 45,
65 | "userhorizon": [
66 | 45,
67 | 25,
68 | 30,
69 | 60
70 | ],
71 | "inverter_paco": 1400
72 | }
73 | ],
74 | "provider": "PVForecastAkkudoktor"
75 | },
76 | "server": {
77 | "startup_eosdash": true,
78 | "host": "127.0.0.1",
79 | "port": 8503,
80 | "eosdash_host": "127.0.0.1",
81 | "eosdash_port": 8504
82 | },
83 | "weather": {
84 | "provider": "BrightSky"
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/footer.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, Union
2 |
3 | import requests
4 | from monsterui.daisy import Loading, LoadingT
5 | from monsterui.franken import A, ButtonT, DivFullySpaced, P
6 | from requests.exceptions import RequestException
7 |
8 | from akkudoktoreos.config.config import get_config
9 | from akkudoktoreos.core.logging import get_logger
10 |
11 | logger = get_logger(__name__)
12 | config_eos = get_config()
13 |
14 |
15 | def get_alive(eos_host: str, eos_port: Union[str, int]) -> str:
16 | """Fetch alive information from the specified EOS server.
17 |
18 | Args:
19 | eos_host (str): The hostname of the server.
20 | eos_port (Union[str, int]): The port of the server.
21 |
22 | Returns:
23 | str: Alive data.
24 | """
25 | result = requests.Response()
26 | try:
27 | result = requests.get(f"http://{eos_host}:{eos_port}/v1/health", timeout=10)
28 | if result.status_code == 200:
29 | alive = result.json()["status"]
30 | else:
31 | alive = f"Server responded with status code: {result.status_code}"
32 | except RequestException as e:
33 | warning_msg = f"{e}"
34 | logger.warning(warning_msg)
35 | alive = warning_msg
36 |
37 | return alive
38 |
39 |
40 | def Footer(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> str:
41 | if eos_host is None:
42 | eos_host = config_eos.server.host
43 | if eos_port is None:
44 | eos_port = config_eos.server.port
45 | alive_icon = None
46 | if eos_host is None or eos_port is None:
47 | alive = "EOS server not given: {eos_host}:{eos_port}"
48 | else:
49 | alive = get_alive(eos_host, eos_port)
50 | if alive == "alive":
51 | alive_icon = Loading(
52 | cls=(
53 | LoadingT.ring,
54 | LoadingT.sm,
55 | ),
56 | )
57 | alive = f"EOS {eos_host}:{eos_port}"
58 | if alive_icon:
59 | alive_cls = f"{ButtonT.primary} uk-link rounded-md"
60 | else:
61 | alive_cls = f"{ButtonT.secondary} uk-link rounded-md"
62 | return DivFullySpaced(
63 | P(
64 | alive_icon,
65 | A(alive, href=f"http://{eos_host}:{eos_port}/docs", target="_blank", cls=alive_cls),
66 | ),
67 | P(
68 | A(
69 | "Documentation",
70 | href="https://akkudoktor-eos.readthedocs.io/en/latest/",
71 | target="_blank",
72 | cls="uk-link",
73 | ),
74 | ),
75 | P(
76 | A(
77 | "Issues",
78 | href="https://github.com/Akkudoktor-EOS/EOS/issues",
79 | target="_blank",
80 | cls="uk-link",
81 | ),
82 | ),
83 | P(
84 | A(
85 | "GitHub",
86 | href="https://github.com/Akkudoktor-EOS/EOS/",
87 | target="_blank",
88 | cls="uk-link",
89 | ),
90 | ),
91 | cls="uk-padding-remove-top uk-padding-remove-botton",
92 | )
93 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/hello.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from fasthtml.common import Div
4 |
5 | from akkudoktoreos.server.dash.markdown import Markdown
6 |
7 | hello_md = """
8 |
9 | # Akkudoktor EOSdash
10 |
11 | The dashboard for Akkudoktor EOS.
12 |
13 | EOS provides a comprehensive solution for simulating and optimizing an energy system based
14 | on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries),
15 | load management (consumer requirements), heat pumps, electric vehicles, and consideration of
16 | electricity price data, this system enables forecasting and optimization of energy flow and costs
17 | over a specified period.
18 |
19 | Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/).
20 | """
21 |
22 |
23 | def Hello(**kwargs: Any) -> Div:
24 | return Markdown(hello_md, **kwargs)
25 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/dash/markdown.py:
--------------------------------------------------------------------------------
1 | """Markdown rendering with MonsterUI HTML classes."""
2 |
3 | from typing import Any, List, Optional, Union
4 |
5 | from fasthtml.common import FT, Div, NotStr
6 | from markdown_it import MarkdownIt
7 | from markdown_it.renderer import RendererHTML
8 | from markdown_it.token import Token
9 | from monsterui.foundations import stringify
10 |
11 |
12 | def render_heading(
13 | self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
14 | ) -> str:
15 | """Custom renderer for Markdown headings.
16 |
17 | Adds specific CSS classes based on the heading level.
18 |
19 | Parameters:
20 | self: The renderer instance.
21 | tokens: List of tokens to be rendered.
22 | idx: Index of the current token.
23 | options: Rendering options.
24 | env: Environment sandbox for plugins.
25 |
26 | Returns:
27 | The rendered token as a string.
28 | """
29 | if tokens[idx].markup == "#":
30 | tokens[idx].attrSet("class", "uk-heading-divider uk-h1 uk-margin")
31 | elif tokens[idx].markup == "##":
32 | tokens[idx].attrSet("class", "uk-heading-divider uk-h2 uk-margin")
33 | elif tokens[idx].markup == "###":
34 | tokens[idx].attrSet("class", "uk-heading-divider uk-h3 uk-margin")
35 | elif tokens[idx].markup == "####":
36 | tokens[idx].attrSet("class", "uk-heading-divider uk-h4 uk-margin")
37 |
38 | # pass token to default renderer.
39 | return self.renderToken(tokens, idx, options, env)
40 |
41 |
42 | def render_paragraph(
43 | self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
44 | ) -> str:
45 | """Custom renderer for Markdown paragraphs.
46 |
47 | Adds specific CSS classes.
48 |
49 | Parameters:
50 | self: The renderer instance.
51 | tokens: List of tokens to be rendered.
52 | idx: Index of the current token.
53 | options: Rendering options.
54 | env: Environment sandbox for plugins.
55 |
56 | Returns:
57 | The rendered token as a string.
58 | """
59 | tokens[idx].attrSet("class", "uk-paragraph")
60 |
61 | # pass token to default renderer.
62 | return self.renderToken(tokens, idx, options, env)
63 |
64 |
65 | def render_blockquote(
66 | self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
67 | ) -> str:
68 | """Custom renderer for Markdown blockquotes.
69 |
70 | Adds specific CSS classes.
71 |
72 | Parameters:
73 | self: The renderer instance.
74 | tokens: List of tokens to be rendered.
75 | idx: Index of the current token.
76 | options: Rendering options.
77 | env: Environment sandbox for plugins.
78 |
79 | Returns:
80 | The rendered token as a string.
81 | """
82 | tokens[idx].attrSet("class", "uk-blockquote")
83 |
84 | # pass token to default renderer.
85 | return self.renderToken(tokens, idx, options, env)
86 |
87 |
88 | def render_link(self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict) -> str:
89 | """Custom renderer for Markdown links.
90 |
91 | Adds the target attribute to open links in a new tab.
92 |
93 | Parameters:
94 | self: The renderer instance.
95 | tokens: List of tokens to be rendered.
96 | idx: Index of the current token.
97 | options: Rendering options.
98 | env: Environment sandbox for plugins.
99 |
100 | Returns:
101 | The rendered token as a string.
102 | """
103 | tokens[idx].attrSet("class", "uk-link")
104 | tokens[idx].attrSet("target", "_blank")
105 |
106 | # pass token to default renderer.
107 | return self.renderToken(tokens, idx, options, env)
108 |
109 |
110 | markdown = MarkdownIt("gfm-like")
111 | markdown.add_render_rule("heading_open", render_heading)
112 | markdown.add_render_rule("paragraph_open", render_paragraph)
113 | markdown.add_render_rule("blockquote_open", render_blockquote)
114 | markdown.add_render_rule("link_open", render_link)
115 |
116 |
117 | markdown_cls = "bg-background text-lg ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
118 |
119 |
120 | def Markdown(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> FT:
121 | """Component to render Markdown content with custom styling.
122 |
123 | Parameters:
124 | c: Markdown content to be rendered.
125 | cls: Optional additional CSS classes to be added.
126 | kwargs: Additional keyword arguments for the Div component.
127 |
128 | Returns:
129 | An FT object representing the rendered HTML content wrapped in a Div component.
130 | """
131 | new_cls = markdown_cls
132 | if cls:
133 | new_cls += f" {stringify(cls)}"
134 | kwargs["cls"] = new_cls
135 | md_html = markdown.render(*c)
136 | return Div(NotStr(md_html), **kwargs)
137 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/rest/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/server/rest/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/rest/error.py:
--------------------------------------------------------------------------------
1 | import html
2 |
3 | ERROR_PAGE_TEMPLATE = """
4 |
5 |
6 |
7 |
8 |
9 | Energy Optimization System (EOS) Error
10 |
70 |
71 |
72 |
73 |
STATUS_CODE
74 |
ERROR_TITLE
75 |
ERROR_MESSAGE
76 |
ERROR_DETAILS
77 |
Back to Home
78 |
79 |
80 |
81 | """
82 |
83 |
84 | def create_error_page(
85 | status_code: str, error_title: str, error_message: str, error_details: str
86 | ) -> str:
87 | """Create an error page by replacing placeholders in the template."""
88 | return (
89 | ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code)
90 | .replace("ERROR_TITLE", error_title)
91 | .replace("ERROR_MESSAGE", html.escape(error_message))
92 | .replace("ERROR_DETAILS", html.escape(error_details))
93 | )
94 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/rest/tasks.py:
--------------------------------------------------------------------------------
1 | """Task handling taken from fastapi-utils/fastapi_utils/tasks.py."""
2 |
3 | from __future__ import annotations
4 |
5 | import asyncio
6 | import logging
7 | from functools import wraps
8 | from typing import Any, Callable, Coroutine, Union
9 |
10 | from starlette.concurrency import run_in_threadpool
11 |
12 | NoArgsNoReturnFuncT = Callable[[], None]
13 | NoArgsNoReturnAsyncFuncT = Callable[[], Coroutine[Any, Any, None]]
14 | ExcArgNoReturnFuncT = Callable[[Exception], None]
15 | ExcArgNoReturnAsyncFuncT = Callable[[Exception], Coroutine[Any, Any, None]]
16 | NoArgsNoReturnAnyFuncT = Union[NoArgsNoReturnFuncT, NoArgsNoReturnAsyncFuncT]
17 | ExcArgNoReturnAnyFuncT = Union[ExcArgNoReturnFuncT, ExcArgNoReturnAsyncFuncT]
18 | NoArgsNoReturnDecorator = Callable[[NoArgsNoReturnAnyFuncT], NoArgsNoReturnAsyncFuncT]
19 |
20 |
21 | async def _handle_func(func: NoArgsNoReturnAnyFuncT) -> None:
22 | if asyncio.iscoroutinefunction(func):
23 | await func()
24 | else:
25 | await run_in_threadpool(func)
26 |
27 |
28 | async def _handle_exc(exc: Exception, on_exception: ExcArgNoReturnAnyFuncT | None) -> None:
29 | if on_exception:
30 | if asyncio.iscoroutinefunction(on_exception):
31 | await on_exception(exc)
32 | else:
33 | await run_in_threadpool(on_exception, exc)
34 |
35 |
36 | def repeat_every(
37 | *,
38 | seconds: float,
39 | wait_first: float | None = None,
40 | logger: logging.Logger | None = None,
41 | raise_exceptions: bool = False,
42 | max_repetitions: int | None = None,
43 | on_complete: NoArgsNoReturnAnyFuncT | None = None,
44 | on_exception: ExcArgNoReturnAnyFuncT | None = None,
45 | ) -> NoArgsNoReturnDecorator:
46 | """A decorator that modifies a function so it is periodically re-executed after its first call.
47 |
48 | The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished
49 | by using `functools.partial` or otherwise wrapping the target function prior to decoration.
50 |
51 | Parameters
52 | ----------
53 | seconds: float
54 | The number of seconds to wait between repeated calls
55 | wait_first: float (default None)
56 | If not None, the function will wait for the given duration before the first call
57 | max_repetitions: Optional[int] (default None)
58 | The maximum number of times to call the repeated function. If `None`, the function is repeated forever.
59 | on_complete: Optional[Callable[[], None]] (default None)
60 | A function to call after the final repetition of the decorated function.
61 | on_exception: Optional[Callable[[Exception], None]] (default None)
62 | A function to call when an exception is raised by the decorated function.
63 | """
64 |
65 | def decorator(func: NoArgsNoReturnAnyFuncT) -> NoArgsNoReturnAsyncFuncT:
66 | """Converts the decorated function into a repeated, periodically-called version."""
67 |
68 | @wraps(func)
69 | async def wrapped() -> None:
70 | async def loop() -> None:
71 | if wait_first is not None:
72 | await asyncio.sleep(wait_first)
73 |
74 | repetitions = 0
75 | while max_repetitions is None or repetitions < max_repetitions:
76 | try:
77 | await _handle_func(func)
78 |
79 | except Exception as exc:
80 | await _handle_exc(exc, on_exception)
81 |
82 | repetitions += 1
83 | await asyncio.sleep(seconds)
84 |
85 | if on_complete:
86 | await _handle_func(on_complete)
87 |
88 | asyncio.ensure_future(loop())
89 |
90 | return wrapped
91 |
92 | return decorator
93 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/server/server.py:
--------------------------------------------------------------------------------
1 | """Server Module."""
2 |
3 | import ipaddress
4 | import re
5 | import time
6 | from typing import Optional, Union
7 |
8 | import psutil
9 | from pydantic import Field, IPvAnyAddress, field_validator
10 |
11 | from akkudoktoreos.config.configabc import SettingsBaseModel
12 | from akkudoktoreos.core.logging import get_logger
13 |
14 | logger = get_logger(__name__)
15 |
16 |
17 | def get_default_host() -> str:
18 | """Default host for EOS."""
19 | return "127.0.0.1"
20 |
21 |
22 | def is_valid_ip_or_hostname(value: str) -> bool:
23 | """Validate whether a string is a valid IP address (IPv4 or IPv6) or hostname.
24 |
25 | This function first attempts to interpret the input as an IP address using the
26 | standard library `ipaddress` module. If that fails, it checks whether the input
27 | is a valid hostname according to RFC 1123, which allows domain names consisting
28 | of alphanumeric characters and hyphens, with specific length and structure rules.
29 |
30 | Args:
31 | value (str): The input string to validate.
32 |
33 | Returns:
34 | bool: True if the input is a valid IP address or hostname, False otherwise.
35 | """
36 | try:
37 | ipaddress.ip_address(value)
38 | return True
39 | except ValueError:
40 | pass
41 |
42 | if len(value) > 253:
43 | return False
44 |
45 | hostname_regex = re.compile(
46 | r"^(?=.{1,253}$)(?!-)[A-Z\d-]{1,63}(? bool:
54 | """Wait for a network port to become free, with timeout.
55 |
56 | Checks if the port is currently in use and logs warnings with process details.
57 | Retries every 3 seconds until timeout is reached.
58 |
59 | Args:
60 | port: The network port number to check
61 | timeout: Maximum seconds to wait (0 means check once without waiting)
62 | waiting_app_name: Name of the application waiting for the port
63 |
64 | Returns:
65 | bool: True if port is free, False if port is still in use after timeout
66 |
67 | Raises:
68 | ValueError: If port number or timeout is invalid
69 | psutil.Error: If there are problems accessing process information
70 | """
71 | if not 0 <= port <= 65535:
72 | raise ValueError(f"Invalid port number: {port}")
73 | if timeout < 0:
74 | raise ValueError(f"Invalid timeout: {timeout}")
75 |
76 | def get_processes_using_port() -> list[dict]:
77 | """Get info about processes using the specified port."""
78 | processes: list[dict] = []
79 | seen_pids: set[int] = set()
80 |
81 | try:
82 | for conn in psutil.net_connections(kind="inet"):
83 | if conn.laddr.port == port and conn.pid not in seen_pids:
84 | try:
85 | process = psutil.Process(conn.pid)
86 | seen_pids.add(conn.pid)
87 | processes.append(process.as_dict(attrs=["pid", "cmdline"]))
88 | except psutil.NoSuchProcess:
89 | continue
90 | except psutil.Error as e:
91 | logger.error(f"Error checking port {port}: {e}")
92 | raise
93 |
94 | return processes
95 |
96 | retries = max(int(timeout / 3), 1) if timeout > 0 else 1
97 |
98 | for _ in range(retries):
99 | process_info = get_processes_using_port()
100 |
101 | if not process_info:
102 | return True
103 |
104 | if timeout <= 0:
105 | break
106 |
107 | logger.info(f"{waiting_app_name} waiting for port {port} to become free...")
108 | time.sleep(3)
109 |
110 | if process_info:
111 | logger.warning(
112 | f"{waiting_app_name} port {port} still in use after waiting {timeout} seconds."
113 | )
114 | for info in process_info:
115 | logger.warning(
116 | f"Process using port - PID: {info['pid']}, Command: {' '.join(info['cmdline'])}"
117 | )
118 | return False
119 |
120 | return True
121 |
122 |
123 | class ServerCommonSettings(SettingsBaseModel):
124 | """Server Configuration."""
125 |
126 | host: Optional[IPvAnyAddress] = Field(
127 | default=get_default_host(), description="EOS server IP address."
128 | )
129 | port: Optional[int] = Field(default=8503, description="EOS server IP port number.")
130 | verbose: Optional[bool] = Field(default=False, description="Enable debug output")
131 | startup_eosdash: Optional[bool] = Field(
132 | default=True, description="EOS server to start EOSdash server."
133 | )
134 | eosdash_host: Optional[IPvAnyAddress] = Field(
135 | default=get_default_host(), description="EOSdash server IP address."
136 | )
137 | eosdash_port: Optional[int] = Field(default=8504, description="EOSdash server IP port number.")
138 |
139 | @field_validator("host", "eosdash_host", mode="before")
140 | def validate_server_host(
141 | cls, value: Optional[Union[str, IPvAnyAddress]]
142 | ) -> Optional[Union[str, IPvAnyAddress]]:
143 | if isinstance(value, str):
144 | if not is_valid_ip_or_hostname(value):
145 | raise ValueError(f"Invalid host: {value}")
146 | if value.lower() in ("localhost", "loopback"):
147 | value = "127.0.0.1"
148 | return value
149 |
150 | @field_validator("port", "eosdash_port")
151 | def validate_server_port(cls, value: Optional[int]) -> Optional[int]:
152 | if value is not None and not (1024 <= value <= 49151):
153 | raise ValueError("Server port number must be between 1024 and 49151.")
154 | return value
155 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/src/akkudoktoreos/utils/__init__.py
--------------------------------------------------------------------------------
/src/akkudoktoreos/utils/docs.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from pydantic.fields import FieldInfo
4 |
5 | from akkudoktoreos.core.pydantic import PydanticBaseModel
6 |
7 |
8 | def get_example_or_default(field_name: str, field_info: FieldInfo, example_ix: int) -> Any:
9 | """Generate a default value for a field, considering constraints."""
10 | if field_info.examples is not None:
11 | try:
12 | return field_info.examples[example_ix]
13 | except IndexError:
14 | return field_info.examples[-1]
15 |
16 | if field_info.default is not None:
17 | return field_info.default
18 |
19 | raise NotImplementedError(f"No default or example provided '{field_name}': {field_info}")
20 |
21 |
22 | def get_model_structure_from_examples(
23 | model_class: type[PydanticBaseModel], multiple: bool
24 | ) -> list[dict[str, Any]]:
25 | """Create a model instance with default or example values, respecting constraints."""
26 | example_max_length = 1
27 |
28 | # Get first field with examples (non-default) to get example_max_length
29 | if multiple:
30 | for _, field_info in model_class.model_fields.items():
31 | if field_info.examples is not None:
32 | example_max_length = len(field_info.examples)
33 | break
34 |
35 | example_data: list[dict[str, Any]] = [{} for _ in range(example_max_length)]
36 |
37 | for field_name, field_info in model_class.model_fields.items():
38 | for example_ix in range(example_max_length):
39 | example_data[example_ix][field_name] = get_example_or_default(
40 | field_name, field_info, example_ix
41 | )
42 | return example_data
43 |
--------------------------------------------------------------------------------
/src/akkudoktoreos/utils/utils.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Any
3 |
4 | import numpy as np
5 |
6 | from akkudoktoreos.config.configabc import SettingsBaseModel
7 | from akkudoktoreos.core.logging import get_logger
8 |
9 | logger = get_logger(__name__)
10 |
11 |
12 | class UtilsCommonSettings(SettingsBaseModel):
13 | """Utils Configuration."""
14 |
15 | pass
16 |
17 |
18 | class NumpyEncoder(json.JSONEncoder):
19 | @classmethod
20 | def convert_numpy(cls, obj: Any) -> tuple[Any, bool]:
21 | if isinstance(obj, np.ndarray):
22 | # Convert NumPy arrays to lists
23 | return [
24 | None if isinstance(x, (int, float)) and np.isnan(x) else x for x in obj.tolist()
25 | ], True
26 | if isinstance(obj, np.generic):
27 | return obj.item(), True # Convert NumPy scalars to native Python types
28 | return obj, False
29 |
30 | def default(self, obj: Any) -> Any:
31 | obj, converted = NumpyEncoder.convert_numpy(obj)
32 | if converted:
33 | return obj
34 | return super(NumpyEncoder, self).default(obj)
35 |
36 | @staticmethod
37 | def dumps(data: Any) -> str:
38 | """Static method to serialize a Python object into a JSON string using NumpyEncoder.
39 |
40 | Args:
41 | data: The Python object to serialize.
42 |
43 | Returns:
44 | str: A JSON string representation of the object.
45 | """
46 | return json.dumps(data, cls=NumpyEncoder)
47 |
48 |
49 | # # Example usage
50 | # start_date = datetime.datetime(2024, 3, 31) # Date of the DST change
51 | # if ist_dst_wechsel(start_date):
52 | # hours = 23 # Adjust to 23 hours for DST change days
53 | # else:
54 | # hours = 24 # Default value for days without DST change
55 |
--------------------------------------------------------------------------------
/tests/test_class_optimize.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 | from typing import Any
4 | from unittest.mock import patch
5 |
6 | import pytest
7 |
8 | from akkudoktoreos.config.config import ConfigEOS
9 | from akkudoktoreos.optimization.genetic import (
10 | OptimizationParameters,
11 | OptimizeResponse,
12 | optimization_problem,
13 | )
14 | from akkudoktoreos.utils.visualize import (
15 | prepare_visualize, # Import the new prepare_visualize
16 | )
17 |
18 | DIR_TESTDATA = Path(__file__).parent / "testdata"
19 |
20 |
21 | def compare_dict(actual: dict[str, Any], expected: dict[str, Any]):
22 | assert set(actual) == set(expected)
23 |
24 | for key, value in expected.items():
25 | if isinstance(value, dict):
26 | assert isinstance(actual[key], dict)
27 | compare_dict(actual[key], value)
28 | elif isinstance(value, list):
29 | assert isinstance(actual[key], list)
30 | assert actual[key] == pytest.approx(value)
31 | else:
32 | assert actual[key] == pytest.approx(value)
33 |
34 |
35 | @pytest.mark.parametrize(
36 | "fn_in, fn_out, ngen",
37 | [
38 | ("optimize_input_1.json", "optimize_result_1.json", 3),
39 | ("optimize_input_2.json", "optimize_result_2.json", 3),
40 | ("optimize_input_2.json", "optimize_result_2_full.json", 400),
41 | ],
42 | )
43 | def test_optimize(
44 | fn_in: str,
45 | fn_out: str,
46 | ngen: int,
47 | config_eos: ConfigEOS,
48 | is_full_run: bool,
49 | ):
50 | """Test optimierung_ems."""
51 | # Assure configuration holds the correct values
52 | config_eos.merge_settings_from_dict(
53 | {"prediction": {"hours": 48}, "optimization": {"hours": 48}}
54 | )
55 |
56 | # Load input and output data
57 | file = DIR_TESTDATA / fn_in
58 | with file.open("r") as f_in:
59 | input_data = OptimizationParameters(**json.load(f_in))
60 |
61 | file = DIR_TESTDATA / fn_out
62 | # In case a new test case is added, we don't want to fail here, so the new output is written to disk before
63 | try:
64 | with file.open("r") as f_out:
65 | expected_result = OptimizeResponse(**json.load(f_out))
66 | except FileNotFoundError:
67 | pass
68 |
69 | opt_class = optimization_problem(fixed_seed=42)
70 | start_hour = 10
71 |
72 | # Activate with pytest --full-run
73 | if ngen > 10 and not is_full_run:
74 | pytest.skip()
75 |
76 | visualize_filename = str((DIR_TESTDATA / f"new_{fn_out}").with_suffix(".pdf"))
77 |
78 | with patch(
79 | "akkudoktoreos.utils.visualize.prepare_visualize",
80 | side_effect=lambda parameters, results, *args, **kwargs: prepare_visualize(
81 | parameters, results, filename=visualize_filename, **kwargs
82 | ),
83 | ) as prepare_visualize_patch:
84 | # Call the optimization function
85 | ergebnis = opt_class.optimierung_ems(
86 | parameters=input_data, start_hour=start_hour, ngen=ngen
87 | )
88 | # Write test output to file, so we can take it as new data on intended change
89 | TESTDATA_FILE = DIR_TESTDATA / f"new_{fn_out}"
90 | with TESTDATA_FILE.open("w", encoding="utf-8", newline="\n") as f_out:
91 | f_out.write(ergebnis.model_dump_json(indent=4, exclude_unset=True))
92 |
93 | assert ergebnis.result.Gesamtbilanz_Euro == pytest.approx(
94 | expected_result.result.Gesamtbilanz_Euro
95 | )
96 |
97 | # Assert that the output contains all expected entries.
98 | # This does not assert that the optimization always gives the same result!
99 | # Reproducibility and mathematical accuracy should be tested on the level of individual components.
100 | compare_dict(ergebnis.model_dump(), expected_result.model_dump())
101 |
102 | # The function creates a visualization result PDF as a side-effect.
103 | prepare_visualize_patch.assert_called_once()
104 | assert Path(visualize_filename).exists()
105 |
--------------------------------------------------------------------------------
/tests/test_configabc.py:
--------------------------------------------------------------------------------
1 | from typing import List, Literal, Optional, no_type_check
2 |
3 | import pytest
4 | from pydantic import Field, ValidationError
5 |
6 | from akkudoktoreos.config.configabc import SettingsBaseModel
7 |
8 |
9 | class SettingsModel(SettingsBaseModel):
10 | name: str = "Default Name"
11 | age: int = 18
12 | tags: List[str] = Field(default_factory=list)
13 | readonly_field: Literal["ReadOnly"] = "ReadOnly" # Use Literal instead of const
14 |
15 |
16 | def test_reset_to_defaults():
17 | """Test resetting to default values."""
18 | instance = SettingsModel(name="Custom Name", age=25, tags=["tag1", "tag2"])
19 |
20 | # Modify the instance
21 | instance.name = "Modified Name"
22 | instance.age = 30
23 | instance.tags.append("tag3")
24 |
25 | # Ensure the instance is modified
26 | assert instance.name == "Modified Name"
27 | assert instance.age == 30
28 | assert instance.tags == ["tag1", "tag2", "tag3"]
29 |
30 | # Reset to defaults
31 | instance.reset_to_defaults()
32 |
33 | # Verify default values
34 | assert instance.name == "Default Name"
35 | assert instance.age == 18
36 | assert instance.tags == []
37 | assert instance.readonly_field == "ReadOnly"
38 |
39 |
40 | @no_type_check
41 | def test_reset_to_defaults_readonly_field():
42 | """Ensure read-only fields remain unchanged."""
43 | instance = SettingsModel()
44 |
45 | # Attempt to modify readonly_field (should raise an error)
46 | with pytest.raises(ValidationError):
47 | instance.readonly_field = "New Value"
48 |
49 | # Reset to defaults
50 | instance.reset_to_defaults()
51 |
52 | # Ensure readonly_field is still at its default value
53 | assert instance.readonly_field == "ReadOnly"
54 |
55 |
56 | def test_reset_to_defaults_with_default_factory():
57 | """Test reset with fields having default_factory."""
58 |
59 | class FactoryModel(SettingsBaseModel):
60 | items: List[int] = Field(default_factory=lambda: [1, 2, 3])
61 | value: Optional[int] = None
62 |
63 | instance = FactoryModel(items=[4, 5, 6], value=10)
64 |
65 | # Ensure instance has custom values
66 | assert instance.items == [4, 5, 6]
67 | assert instance.value == 10
68 |
69 | # Reset to defaults
70 | instance.reset_to_defaults()
71 |
72 | # Verify reset values
73 | assert instance.items == [1, 2, 3]
74 | assert instance.value is None
75 |
76 |
77 | @no_type_check
78 | def test_reset_to_defaults_error_handling():
79 | """Ensure reset_to_defaults skips fields that cannot be set."""
80 |
81 | class ReadOnlyModel(SettingsBaseModel):
82 | readonly_field: Literal["ReadOnly"] = "ReadOnly"
83 |
84 | instance = ReadOnlyModel()
85 |
86 | # Attempt to modify readonly_field (should raise an error)
87 | with pytest.raises(ValidationError):
88 | instance.readonly_field = "New Value"
89 |
90 | # Reset to defaults
91 | instance.reset_to_defaults()
92 |
93 | # Ensure readonly_field is unaffected
94 | assert instance.readonly_field == "ReadOnly"
95 |
--------------------------------------------------------------------------------
/tests/test_doc.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import sys
4 | from pathlib import Path
5 | from unittest.mock import patch
6 |
7 | import pytest
8 |
9 | DIR_PROJECT_ROOT = Path(__file__).parent.parent
10 | DIR_TESTDATA = Path(__file__).parent / "testdata"
11 |
12 |
13 | def test_openapi_spec_current(config_eos):
14 | """Verify the openapi spec hasn´t changed."""
15 | expected_spec_path = DIR_PROJECT_ROOT / "openapi.json"
16 | new_spec_path = DIR_TESTDATA / "openapi-new.json"
17 |
18 | with expected_spec_path.open("r", encoding="utf-8", newline=None) as f_expected:
19 | expected_spec = json.load(f_expected)
20 |
21 | # Patch get_config and import within guard to patch global variables within the eos module.
22 | with patch("akkudoktoreos.config.config.get_config", return_value=config_eos):
23 | # Ensure the script works correctly as part of a package
24 | root_dir = Path(__file__).resolve().parent.parent
25 | sys.path.insert(0, str(root_dir))
26 | from scripts import generate_openapi
27 |
28 | spec = generate_openapi.generate_openapi()
29 | spec_str = json.dumps(spec, indent=4, sort_keys=True)
30 |
31 | with new_spec_path.open("w", encoding="utf-8", newline="\n") as f_new:
32 | f_new.write(spec_str)
33 |
34 | # Serialize to ensure comparison is consistent
35 | expected_spec_str = json.dumps(expected_spec, indent=4, sort_keys=True)
36 |
37 | try:
38 | assert spec_str == expected_spec_str
39 | except AssertionError as e:
40 | pytest.fail(
41 | f"Expected {new_spec_path} to equal {expected_spec_path}.\n"
42 | + f"If ok: `make gen-docs` or `cp {new_spec_path} {expected_spec_path}`\n"
43 | )
44 |
45 |
46 | def test_openapi_md_current(config_eos):
47 | """Verify the generated openapi markdown hasn´t changed."""
48 | expected_spec_md_path = DIR_PROJECT_ROOT / "docs" / "_generated" / "openapi.md"
49 | new_spec_md_path = DIR_TESTDATA / "openapi-new.md"
50 |
51 | with expected_spec_md_path.open("r", encoding="utf-8", newline=None) as f_expected:
52 | expected_spec_md = f_expected.read()
53 |
54 | # Patch get_config and import within guard to patch global variables within the eos module.
55 | with patch("akkudoktoreos.config.config.get_config", return_value=config_eos):
56 | # Ensure the script works correctly as part of a package
57 | root_dir = Path(__file__).resolve().parent.parent
58 | sys.path.insert(0, str(root_dir))
59 | from scripts import generate_openapi_md
60 |
61 | spec_md = generate_openapi_md.generate_openapi_md()
62 |
63 | with new_spec_md_path.open("w", encoding="utf-8", newline="\n") as f_new:
64 | f_new.write(spec_md)
65 |
66 | try:
67 | assert spec_md == expected_spec_md
68 | except AssertionError as e:
69 | pytest.fail(
70 | f"Expected {new_spec_md_path} to equal {expected_spec_md_path}.\n"
71 | + f"If ok: `make gen-docs` or `cp {new_spec_md_path} {expected_spec_md_path}`\n"
72 | )
73 |
74 |
75 | def test_config_md_current(config_eos):
76 | """Verify the generated configuration markdown hasn´t changed."""
77 | expected_config_md_path = DIR_PROJECT_ROOT / "docs" / "_generated" / "config.md"
78 | new_config_md_path = DIR_TESTDATA / "config-new.md"
79 |
80 | with expected_config_md_path.open("r", encoding="utf-8", newline=None) as f_expected:
81 | expected_config_md = f_expected.read()
82 |
83 | # Patch get_config and import within guard to patch global variables within the eos module.
84 | with patch("akkudoktoreos.config.config.get_config", return_value=config_eos):
85 | # Ensure the script works correctly as part of a package
86 | root_dir = Path(__file__).resolve().parent.parent
87 | sys.path.insert(0, str(root_dir))
88 | from scripts import generate_config_md
89 |
90 | config_md = generate_config_md.generate_config_md(config_eos)
91 |
92 | if os.name == "nt":
93 | config_md = config_md.replace("\\\\", "/")
94 | with new_config_md_path.open("w", encoding="utf-8", newline="\n") as f_new:
95 | f_new.write(config_md)
96 |
97 | try:
98 | assert config_md == expected_config_md
99 | except AssertionError as e:
100 | pytest.fail(
101 | f"Expected {new_config_md_path} to equal {expected_config_md_path}.\n"
102 | + f"If ok: `make gen-docs` or `cp {new_config_md_path} {expected_config_md_path}`\n"
103 | )
104 |
--------------------------------------------------------------------------------
/tests/test_elecpriceimport.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 |
4 | import pytest
5 |
6 | from akkudoktoreos.core.ems import get_ems
7 | from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
8 | from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
9 |
10 | DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
11 |
12 | FILE_TESTDATA_ELECPRICEIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
13 |
14 |
15 | @pytest.fixture
16 | def provider(sample_import_1_json, config_eos):
17 | """Fixture to create a ElecPriceProvider instance."""
18 | settings = {
19 | "elecprice": {
20 | "provider": "ElecPriceImport",
21 | "provider_settings": {
22 | "import_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
23 | "import_json": json.dumps(sample_import_1_json),
24 | },
25 | }
26 | }
27 | config_eos.merge_settings_from_dict(settings)
28 | provider = ElecPriceImport()
29 | assert provider.enabled()
30 | return provider
31 |
32 |
33 | @pytest.fixture
34 | def sample_import_1_json():
35 | """Fixture that returns sample forecast data report."""
36 | with FILE_TESTDATA_ELECPRICEIMPORT_1_JSON.open("r", encoding="utf-8", newline=None) as f_res:
37 | input_data = json.load(f_res)
38 | return input_data
39 |
40 |
41 | # ------------------------------------------------
42 | # General forecast
43 | # ------------------------------------------------
44 |
45 |
46 | def test_singleton_instance(provider):
47 | """Test that ElecPriceForecast behaves as a singleton."""
48 | another_instance = ElecPriceImport()
49 | assert provider is another_instance
50 |
51 |
52 | def test_invalid_provider(provider, config_eos):
53 | """Test requesting an unsupported provider."""
54 | settings = {
55 | "elecprice": {
56 | "provider": "",
57 | "provider_settings": {
58 | "import_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
59 | },
60 | }
61 | }
62 | with pytest.raises(ValueError, match="not a valid electricity price provider"):
63 | config_eos.merge_settings_from_dict(settings)
64 |
65 |
66 | # ------------------------------------------------
67 | # Import
68 | # ------------------------------------------------
69 |
70 |
71 | @pytest.mark.parametrize(
72 | "start_datetime, from_file",
73 | [
74 | ("2024-11-10 00:00:00", True), # No DST in Germany
75 | ("2024-08-10 00:00:00", True), # DST in Germany
76 | ("2024-03-31 00:00:00", True), # DST change in Germany (23 hours/ day)
77 | ("2024-10-27 00:00:00", True), # DST change in Germany (25 hours/ day)
78 | ("2024-11-10 00:00:00", False), # No DST in Germany
79 | ("2024-08-10 00:00:00", False), # DST in Germany
80 | ("2024-03-31 00:00:00", False), # DST change in Germany (23 hours/ day)
81 | ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
82 | ],
83 | )
84 | def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
85 | """Test fetching forecast from Import."""
86 | ems_eos = get_ems()
87 | ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
88 | if from_file:
89 | config_eos.elecprice.provider_settings.import_json = None
90 | assert config_eos.elecprice.provider_settings.import_json is None
91 | else:
92 | config_eos.elecprice.provider_settings.import_file_path = None
93 | assert config_eos.elecprice.provider_settings.import_file_path is None
94 | provider.clear()
95 |
96 | # Call the method
97 | provider.update_data()
98 |
99 | # Assert: Verify the result is as expected
100 | assert provider.start_datetime is not None
101 | assert provider.total_hours is not None
102 | assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
103 | values = sample_import_1_json["elecprice_marketprice_wh"]
104 | value_datetime_mapping = provider.import_datetimes(ems_eos.start_datetime, len(values))
105 | for i, mapping in enumerate(value_datetime_mapping):
106 | assert i < len(provider.records)
107 | expected_datetime, expected_value_index = mapping
108 | expected_value = values[expected_value_index]
109 | result_datetime = provider.records[i].date_time
110 | result_value = provider.records[i]["elecprice_marketprice_wh"]
111 |
112 | # print(f"{i}: Expected: {expected_datetime}:{expected_value}")
113 | # print(f"{i}: Result: {result_datetime}:{result_value}")
114 | assert compare_datetimes(result_datetime, expected_datetime).equal
115 | assert result_value == expected_value
116 |
--------------------------------------------------------------------------------
/tests/test_eosdashconfig.py:
--------------------------------------------------------------------------------
1 | """Test suite for the EOS Dash configuration module.
2 |
3 | This module contains tests for utility functions related to retrieving and processing
4 | configuration data using Pydantic models.
5 | """
6 |
7 | import json
8 | from pathlib import Path
9 | from typing import Union
10 |
11 | import pytest
12 | from pydantic.fields import FieldInfo
13 |
14 | from akkudoktoreos.core.pydantic import PydanticBaseModel
15 | from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
16 | from akkudoktoreos.server.dash.configuration import (
17 | configuration,
18 | get_default_value,
19 | get_nested_value,
20 | resolve_nested_types,
21 | )
22 |
23 | DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
24 |
25 | FILE_TESTDATA_EOSSERVER_CONFIG_1 = DIR_TESTDATA.joinpath("eosserver_config_1.json")
26 |
27 |
28 | class SampleModel(PydanticBaseModel):
29 | field1: str = "default_value"
30 | field2: int = 10
31 |
32 |
33 | class TestEOSdashConfig:
34 | """Test case for EOS Dash configuration utility functions.
35 |
36 | This class tests functions for retrieving nested values, extracting default values,
37 | resolving nested types, and generating configuration details from Pydantic models.
38 | """
39 |
40 | def test_get_nested_value_from_dict(self):
41 | """Test retrieving a nested value from a dictionary using a sequence of keys."""
42 | data = {"a": {"b": {"c": 42}}}
43 | assert get_nested_value(data, ["a", "b", "c"]) == 42
44 | assert get_nested_value(data, ["a", "x"], default="not found") == "not found"
45 | with pytest.raises(TypeError):
46 | get_nested_value("not_a_dict", ["a"]) # type: ignore
47 |
48 | def test_get_nested_value_from_list(self):
49 | """Test retrieving a nested value from a list using a sequence of keys."""
50 | data = {"a": {"b": {"c": [42]}}}
51 | assert get_nested_value(data, ["a", "b", "c", 0]) == 42
52 | assert get_nested_value(data, ["a", "b", "c", "0"]) == 42
53 |
54 | def test_get_default_value(self):
55 | """Test retrieving the default value of a field based on FieldInfo metadata."""
56 | field_info = FieldInfo(default="test_value")
57 | assert get_default_value(field_info, True) == "test_value"
58 | field_info_no_default = FieldInfo()
59 | assert get_default_value(field_info_no_default, True) == ""
60 | assert get_default_value(field_info, False) == "N/A"
61 |
62 | def test_resolve_nested_types(self):
63 | """Test resolving nested types within a field, ensuring correct type extraction."""
64 | nested_types = resolve_nested_types(Union[int, str], [])
65 | assert (int, []) in nested_types
66 | assert (str, []) in nested_types
67 |
68 | def test_configuration(self):
69 | """Test extracting configuration details from a Pydantic model based on provided values."""
70 | values = {"field1": "custom_value", "field2": 20}
71 | config = configuration(SampleModel, values)
72 | assert any(
73 | item["name"] == "field1" and item["value"] == '"custom_value"' for item in config
74 | )
75 | assert any(item["name"] == "field2" and item["value"] == "20" for item in config)
76 |
77 | def test_configuration_eos(self, config_eos):
78 | """Test extracting EOS configuration details from EOS config based on provided values."""
79 | with FILE_TESTDATA_EOSSERVER_CONFIG_1.open("r", encoding="utf-8", newline=None) as fd:
80 | values = json.load(fd)
81 | config = configuration(config_eos, values)
82 | assert any(
83 | item["name"] == "server.eosdash_port" and item["value"] == "8504" for item in config
84 | )
85 | assert any(
86 | item["name"] == "server.eosdash_host" and item["value"] == '"127.0.0.1"'
87 | for item in config
88 | )
89 |
90 | def test_configuration_pvforecast_plane_settings(self):
91 | """Test extracting EOS PV forecast plane configuration details from EOS config based on provided values."""
92 | with FILE_TESTDATA_EOSSERVER_CONFIG_1.open("r", encoding="utf-8", newline=None) as fd:
93 | values = json.load(fd)
94 | config = configuration(
95 | PVForecastPlaneSetting(), values, values_prefix=["pvforecast", "planes", "0"]
96 | )
97 | assert any(
98 | item["name"] == "pvforecast.planes.0.surface_azimuth" and item["value"] == "170"
99 | for item in config
100 | )
101 | assert any(
102 | item["name"] == "pvforecast.planes.0.userhorizon"
103 | and item["value"] == "[20, 27, 22, 20]"
104 | for item in config
105 | )
106 |
--------------------------------------------------------------------------------
/tests/test_eosdashserver.py:
--------------------------------------------------------------------------------
1 | import time
2 | from http import HTTPStatus
3 |
4 | import requests
5 |
6 |
7 | class TestEOSDash:
8 | def test_eosdash_started(self, server_setup_for_class, is_system_test):
9 | """Test the EOSdash server is started by EOS server."""
10 | server = server_setup_for_class["server"]
11 | eosdash_server = server_setup_for_class["eosdash_server"]
12 | eos_dir = server_setup_for_class["eos_dir"]
13 | timeout = server_setup_for_class["timeout"]
14 |
15 | # Assure EOSdash is up
16 | startup = False
17 | error = ""
18 | for retries in range(int(timeout / 3)):
19 | try:
20 | result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
21 | if result.status_code == HTTPStatus.OK:
22 | startup = True
23 | break
24 | error = f"{result.status_code}, {str(result.content)}"
25 | except Exception as ex:
26 | error = str(ex)
27 | time.sleep(3)
28 | assert startup, f"Connection to {eosdash_server}/eosdash/health failed: {error}"
29 | assert result.json()["status"] == "alive"
30 |
31 | def test_eosdash_proxied_by_eos(self, server_setup_for_class, is_system_test):
32 | """Test the EOSdash server proxied by EOS server."""
33 | server = server_setup_for_class["server"]
34 | eos_dir = server_setup_for_class["eos_dir"]
35 | timeout = server_setup_for_class["timeout"]
36 |
37 | # Assure EOSdash is up
38 | startup = False
39 | error = ""
40 | for retries in range(int(timeout / 3)):
41 | try:
42 | result = requests.get(f"{server}/eosdash/health", timeout=2)
43 | if result.status_code == HTTPStatus.OK:
44 | startup = True
45 | break
46 | error = f"{result.status_code}, {str(result.content)}"
47 | except Exception as ex:
48 | error = str(ex)
49 | time.sleep(3)
50 | assert startup, f"Connection to {server}/eosdash/health failed: {error}"
51 | assert result.json()["status"] == "alive"
52 |
--------------------------------------------------------------------------------
/tests/test_heatpump.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from akkudoktoreos.devices.heatpump import Heatpump
4 |
5 |
6 | @pytest.fixture(scope="function")
7 | def hp_5kw_24h() -> Heatpump:
8 | """Heatpump with 5 kW heating power and 24 h prediction."""
9 | return Heatpump(5000, 24)
10 |
11 |
12 | class TestHeatpump:
13 | def test_cop(self, hp_5kw_24h: Heatpump):
14 | """Testing calculate COP for various outside temperatures."""
15 | assert hp_5kw_24h.calculate_cop(-10) == 2.0
16 | assert hp_5kw_24h.calculate_cop(0) == 3.0
17 | assert hp_5kw_24h.calculate_cop(10) == 4.0
18 | # Check edge case for outside temperature
19 | out_temp_min = -100.1
20 | out_temp_max = 100.1
21 | with pytest.raises(ValueError, match=f"'{out_temp_min}' not in range"):
22 | hp_5kw_24h.calculate_cop(out_temp_min)
23 | with pytest.raises(ValueError, match=f"'{out_temp_max}' not in range"):
24 | hp_5kw_24h.calculate_cop(out_temp_max)
25 |
26 | def test_heating_output(self, hp_5kw_24h: Heatpump):
27 | """Testing calculation of heating output."""
28 | assert hp_5kw_24h.calculate_heating_output(-10.0) == 5000
29 | assert hp_5kw_24h.calculate_heating_output(0.0) == 5000
30 | assert hp_5kw_24h.calculate_heating_output(10.0) == pytest.approx(4939.583)
31 |
32 | def test_heating_power(self, hp_5kw_24h: Heatpump):
33 | """Testing calculation of heating power."""
34 | assert hp_5kw_24h.calculate_heat_power(-10.0) == 2104
35 | assert hp_5kw_24h.calculate_heat_power(0.0) == 1164
36 | assert hp_5kw_24h.calculate_heat_power(10.0) == 548
37 |
--------------------------------------------------------------------------------
/tests/test_logging.py:
--------------------------------------------------------------------------------
1 | """Test Module for logging Module."""
2 |
3 | import logging
4 | from logging.handlers import RotatingFileHandler
5 | from pathlib import Path
6 |
7 | import pytest
8 |
9 | from akkudoktoreos.core.logging import get_logger
10 |
11 | # -----------------------------
12 | # get_logger
13 | # -----------------------------
14 |
15 |
16 | def test_get_logger_console_logging():
17 | """Test logger creation with console logging."""
18 | logger = get_logger("test_logger", logging_level="DEBUG")
19 |
20 | # Check logger name
21 | assert logger.name == "test_logger"
22 |
23 | # Check logger level
24 | assert logger.level == logging.DEBUG
25 |
26 | # Check console handler is present
27 | assert len(logger.handlers) == 1
28 | assert isinstance(logger.handlers[0], logging.StreamHandler)
29 |
30 |
31 | def test_get_logger_file_logging(tmpdir):
32 | """Test logger creation with file logging."""
33 | log_file = Path(tmpdir).joinpath("test.log")
34 | logger = get_logger("test_logger", log_file=str(log_file), logging_level="WARNING")
35 |
36 | # Check logger name
37 | assert logger.name == "test_logger"
38 |
39 | # Check logger level
40 | assert logger.level == logging.WARNING
41 |
42 | # Check console handler is present
43 | assert len(logger.handlers) == 2 # One for console and one for file
44 | assert isinstance(logger.handlers[0], logging.StreamHandler)
45 | assert isinstance(logger.handlers[1], RotatingFileHandler)
46 |
47 | # Check file existence
48 | assert log_file.exists()
49 |
50 |
51 | def test_get_logger_no_file_logging():
52 | """Test logger creation without file logging."""
53 | logger = get_logger("test_logger")
54 |
55 | # Check logger name
56 | assert logger.name == "test_logger"
57 |
58 | # Check logger level
59 | assert logger.level == logging.INFO
60 |
61 | # Check no file handler is present
62 | assert len(logger.handlers) >= 1 # First is console handler (maybe be pytest handler)
63 | assert isinstance(logger.handlers[0], logging.StreamHandler)
64 |
65 |
66 | def test_get_logger_with_invalid_level():
67 | """Test logger creation with an invalid logging level."""
68 | with pytest.raises(ValueError, match="Unknown loggin level: INVALID"):
69 | logger = get_logger("test_logger", logging_level="INVALID")
70 |
--------------------------------------------------------------------------------
/tests/test_prediction.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from pydantic import ValidationError
3 |
4 | from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
5 | from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
6 | from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
7 | from akkudoktoreos.prediction.loadimport import LoadImport
8 | from akkudoktoreos.prediction.prediction import (
9 | Prediction,
10 | PredictionCommonSettings,
11 | get_prediction,
12 | )
13 | from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
14 | from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
15 | from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
16 | from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
17 | from akkudoktoreos.prediction.weatherimport import WeatherImport
18 |
19 |
20 | @pytest.fixture
21 | def prediction():
22 | """All EOS predictions."""
23 | return get_prediction()
24 |
25 |
26 | @pytest.fixture
27 | def forecast_providers():
28 | """Fixture for singleton forecast provider instances."""
29 | return [
30 | ElecPriceAkkudoktor(),
31 | ElecPriceImport(),
32 | LoadAkkudoktor(),
33 | LoadImport(),
34 | PVForecastAkkudoktor(),
35 | PVForecastImport(),
36 | WeatherBrightSky(),
37 | WeatherClearOutside(),
38 | WeatherImport(),
39 | ]
40 |
41 |
42 | @pytest.mark.parametrize(
43 | "field_name, invalid_value, expected_error",
44 | [
45 | ("hours", -1, "Input should be greater than or equal to 0"),
46 | ("historic_hours", -5, "Input should be greater than or equal to 0"),
47 | ],
48 | )
49 | def test_prediction_common_settings_invalid(field_name, invalid_value, expected_error, config_eos):
50 | """Test invalid settings for PredictionCommonSettings."""
51 | valid_data = {
52 | "hours": 48,
53 | "historic_hours": 24,
54 | }
55 | assert PredictionCommonSettings(**valid_data) is not None
56 | valid_data[field_name] = invalid_value
57 |
58 | with pytest.raises(ValidationError, match=expected_error):
59 | PredictionCommonSettings(**valid_data)
60 |
61 |
62 | def test_initialization(prediction, forecast_providers):
63 | """Test that Prediction is initialized with the correct providers in sequence."""
64 | assert isinstance(prediction, Prediction)
65 | assert prediction.providers == forecast_providers
66 |
67 |
68 | def test_provider_sequence(prediction):
69 | """Test the provider sequence is maintained in the Prediction instance."""
70 | assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
71 | assert isinstance(prediction.providers[1], ElecPriceImport)
72 | assert isinstance(prediction.providers[2], LoadAkkudoktor)
73 | assert isinstance(prediction.providers[3], LoadImport)
74 | assert isinstance(prediction.providers[4], PVForecastAkkudoktor)
75 | assert isinstance(prediction.providers[5], PVForecastImport)
76 | assert isinstance(prediction.providers[6], WeatherBrightSky)
77 | assert isinstance(prediction.providers[7], WeatherClearOutside)
78 | assert isinstance(prediction.providers[8], WeatherImport)
79 |
80 |
81 | def test_provider_by_id(prediction, forecast_providers):
82 | """Test that provider_by_id method returns the correct provider."""
83 | for provider in forecast_providers:
84 | assert prediction.provider_by_id(provider.provider_id()) == provider
85 |
86 |
87 | def test_prediction_repr(prediction):
88 | """Test that the Prediction instance's representation is correct."""
89 | result = repr(prediction)
90 | assert "Prediction([" in result
91 | assert "ElecPriceAkkudoktor" in result
92 | assert "ElecPriceImport" in result
93 | assert "LoadAkkudoktor" in result
94 | assert "LoadImport" in result
95 | assert "PVForecastAkkudoktor" in result
96 | assert "PVForecastImport" in result
97 | assert "WeatherBrightSky" in result
98 | assert "WeatherClearOutside" in result
99 | assert "WeatherImport" in result
100 |
101 |
102 | def test_empty_providers(prediction, forecast_providers):
103 | """Test behavior when Prediction does not have providers."""
104 | # Clear all prediction providers from prediction
105 | providers_bkup = prediction.providers.copy()
106 | prediction.providers.clear()
107 | assert prediction.providers == []
108 | prediction.update_data() # Should not raise an error even with no providers
109 |
110 | # Cleanup after Test
111 | prediction.providers = providers_bkup
112 |
--------------------------------------------------------------------------------
/tests/test_pvforecast.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from akkudoktoreos.prediction.pvforecast import (
4 | PVForecastCommonSettings,
5 | PVForecastPlaneSetting,
6 | )
7 |
8 |
9 | @pytest.fixture
10 | def settings():
11 | """Fixture that creates an empty PVForecastSettings."""
12 | settings = PVForecastCommonSettings()
13 | assert settings.planes is None
14 | return settings
15 |
16 |
17 | def test_planes_peakpower_computation(settings):
18 | """Test computation of peak power for active planes."""
19 | settings.planes = [
20 | PVForecastPlaneSetting(
21 | surface_tilt=10.0,
22 | surface_azimuth=10.0,
23 | peakpower=5.0,
24 | ),
25 | PVForecastPlaneSetting(
26 | surface_tilt=20.0,
27 | surface_azimuth=20.0,
28 | peakpower=3.5,
29 | ),
30 | PVForecastPlaneSetting(
31 | surface_tilt=30.0,
32 | surface_azimuth=30.0,
33 | modules_per_string=20, # Should use default 5000W
34 | ),
35 | ]
36 |
37 | expected_peakpower = [5.0, 3.5, 5000.0]
38 | assert settings.planes_peakpower == expected_peakpower
39 |
40 |
41 | def test_planes_azimuth_computation(settings):
42 | """Test computation of azimuth values for active planes."""
43 | settings.planes = [
44 | PVForecastPlaneSetting(
45 | surface_tilt=10.0,
46 | surface_azimuth=10.0,
47 | ),
48 | PVForecastPlaneSetting(
49 | surface_tilt=20.0,
50 | surface_azimuth=20.0,
51 | ),
52 | ]
53 |
54 | expected_azimuths = [10.0, 20.0]
55 | assert settings.planes_azimuth == expected_azimuths
56 |
57 |
58 | def test_planes_tilt_computation(settings):
59 | """Test computation of tilt values for active planes."""
60 | settings.planes = [
61 | PVForecastPlaneSetting(
62 | surface_tilt=10.0,
63 | surface_azimuth=10.0,
64 | ),
65 | PVForecastPlaneSetting(
66 | surface_tilt=20.0,
67 | surface_azimuth=20.0,
68 | ),
69 | ]
70 |
71 | expected_tilts = [10.0, 20.0]
72 | assert settings.planes_tilt == expected_tilts
73 |
74 |
75 | def test_planes_userhorizon_computation(settings):
76 | """Test computation of user horizon values for active planes."""
77 | horizon1 = [10.0, 20.0, 30.0]
78 | horizon2 = [5.0, 15.0, 25.0]
79 |
80 | settings.planes = [
81 | PVForecastPlaneSetting(
82 | surface_tilt=10.0,
83 | surface_azimuth=10.0,
84 | userhorizon=horizon1,
85 | ),
86 | PVForecastPlaneSetting(
87 | surface_tilt=20.0,
88 | surface_azimuth=20.0,
89 | userhorizon=horizon2,
90 | ),
91 | ]
92 |
93 | expected_horizons = [horizon1, horizon2]
94 | assert settings.planes_userhorizon == expected_horizons
95 |
96 |
97 | def test_planes_inverter_paco_computation(settings):
98 | """Test computation of inverter power rating for active planes."""
99 | settings.planes = [
100 | PVForecastPlaneSetting(
101 | surface_tilt=10.0,
102 | surface_azimuth=10.0,
103 | inverter_paco=6000,
104 | ),
105 | PVForecastPlaneSetting(
106 | surface_tilt=20.0,
107 | surface_azimuth=20.0,
108 | inverter_paco=4000,
109 | ),
110 | ]
111 |
112 | expected_paco = [6000, 4000]
113 | assert settings.planes_inverter_paco == expected_paco
114 |
115 |
116 | def test_mixed_plane_configuration(settings):
117 | """Test mixed configuration with some planes having peak power and others having modules."""
118 | settings.planes = [
119 | PVForecastPlaneSetting(
120 | surface_tilt=10.0,
121 | surface_azimuth=10.0,
122 | peakpower=5.0,
123 | ),
124 | PVForecastPlaneSetting(
125 | surface_tilt=20.0,
126 | surface_azimuth=20.0,
127 | modules_per_string=20,
128 | strings_per_inverter=2,
129 | ),
130 | PVForecastPlaneSetting(
131 | surface_tilt=40.0,
132 | surface_azimuth=40.0,
133 | peakpower=3.0,
134 | ),
135 | ]
136 |
137 | # First plane uses specified peak power, second uses default, third uses specified
138 | assert settings.planes_peakpower == [5.0, 5000.0, 3.0]
139 |
140 |
141 | def test_none_plane_settings():
142 | """Test that optional parameters can be None for non-zero planes."""
143 | setting = PVForecastPlaneSetting(
144 | peakpower=5.0,
145 | albedo=None,
146 | module_model=None,
147 | userhorizon=None,
148 | )
149 |
--------------------------------------------------------------------------------
/tests/test_pvforecastimport.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 |
4 | import pytest
5 |
6 | from akkudoktoreos.core.ems import get_ems
7 | from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
8 | from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
9 |
10 | DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
11 |
12 | FILE_TESTDATA_PVFORECASTIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
13 |
14 |
15 | @pytest.fixture
16 | def provider(sample_import_1_json, config_eos):
17 | """Fixture to create a PVForecastProvider instance."""
18 | settings = {
19 | "pvforecast": {
20 | "provider": "PVForecastImport",
21 | "provider_settings": {
22 | "import_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
23 | "import_json": json.dumps(sample_import_1_json),
24 | },
25 | }
26 | }
27 | config_eos.merge_settings_from_dict(settings)
28 | provider = PVForecastImport()
29 | assert provider.enabled()
30 | return provider
31 |
32 |
33 | @pytest.fixture
34 | def sample_import_1_json():
35 | """Fixture that returns sample forecast data report."""
36 | with FILE_TESTDATA_PVFORECASTIMPORT_1_JSON.open("r", encoding="utf-8", newline=None) as f_res:
37 | input_data = json.load(f_res)
38 | return input_data
39 |
40 |
41 | # ------------------------------------------------
42 | # General forecast
43 | # ------------------------------------------------
44 |
45 |
46 | def test_singleton_instance(provider):
47 | """Test that PVForecastForecast behaves as a singleton."""
48 | another_instance = PVForecastImport()
49 | assert provider is another_instance
50 |
51 |
52 | def test_invalid_provider(provider, config_eos):
53 | """Test requesting an unsupported provider."""
54 | settings = {
55 | "pvforecast": {
56 | "provider": "",
57 | "provider_settings": {
58 | "import_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
59 | },
60 | }
61 | }
62 | with pytest.raises(ValueError, match="not a valid PV forecast provider"):
63 | config_eos.merge_settings_from_dict(settings)
64 |
65 |
66 | # ------------------------------------------------
67 | # Import
68 | # ------------------------------------------------
69 |
70 |
71 | @pytest.mark.parametrize(
72 | "start_datetime, from_file",
73 | [
74 | ("2024-11-10 00:00:00", True), # No DST in Germany
75 | ("2024-08-10 00:00:00", True), # DST in Germany
76 | ("2024-03-31 00:00:00", True), # DST change in Germany (23 hours/ day)
77 | ("2024-10-27 00:00:00", True), # DST change in Germany (25 hours/ day)
78 | ("2024-11-10 00:00:00", False), # No DST in Germany
79 | ("2024-08-10 00:00:00", False), # DST in Germany
80 | ("2024-03-31 00:00:00", False), # DST change in Germany (23 hours/ day)
81 | ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
82 | ],
83 | )
84 | def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
85 | """Test fetching forecast from import."""
86 | ems_eos = get_ems()
87 | ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
88 | if from_file:
89 | config_eos.pvforecast.provider_settings.import_json = None
90 | assert config_eos.pvforecast.provider_settings.import_json is None
91 | else:
92 | config_eos.pvforecast.provider_settings.import_file_path = None
93 | assert config_eos.pvforecast.provider_settings.import_file_path is None
94 | provider.clear()
95 |
96 | # Call the method
97 | provider.update_data()
98 |
99 | # Assert: Verify the result is as expected
100 | assert provider.start_datetime is not None
101 | assert provider.total_hours is not None
102 | assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
103 | values = sample_import_1_json["pvforecast_ac_power"]
104 | value_datetime_mapping = provider.import_datetimes(ems_eos.start_datetime, len(values))
105 | for i, mapping in enumerate(value_datetime_mapping):
106 | assert i < len(provider.records)
107 | expected_datetime, expected_value_index = mapping
108 | expected_value = values[expected_value_index]
109 | result_datetime = provider.records[i].date_time
110 | result_value = provider.records[i]["pvforecast_ac_power"]
111 |
112 | # print(f"{i}: Expected: {expected_datetime}:{expected_value}")
113 | # print(f"{i}: Result: {result_datetime}:{result_value}")
114 | assert compare_datetimes(result_datetime, expected_datetime).equal
115 | assert result_value == expected_value
116 |
--------------------------------------------------------------------------------
/tests/test_visualize.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from matplotlib.testing.compare import compare_images
4 |
5 | from akkudoktoreos.utils.visualize import generate_example_report
6 |
7 | filename = "example_report.pdf"
8 |
9 |
10 | DIR_TESTDATA = Path(__file__).parent / "testdata"
11 | reference_file = DIR_TESTDATA / "test_example_report.pdf"
12 |
13 |
14 | def test_generate_pdf_example(config_eos):
15 | """Test generation of example visualization report."""
16 | output_dir = config_eos.general.data_output_path
17 | assert output_dir is not None
18 | output_file = output_dir / filename
19 | assert not output_file.exists()
20 |
21 | # Generate PDF
22 | generate_example_report()
23 |
24 | # Check if the file exists
25 | assert output_file.exists()
26 |
27 | # Compare the generated file with the reference file
28 | comparison = compare_images(str(reference_file), str(output_file), tol=0)
29 |
30 | # Assert that there are no differences
31 | assert comparison is None, f"Images differ: {comparison}"
32 |
--------------------------------------------------------------------------------
/tests/test_weatherimport.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 |
4 | import pytest
5 |
6 | from akkudoktoreos.core.ems import get_ems
7 | from akkudoktoreos.prediction.weatherimport import WeatherImport
8 | from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
9 |
10 | DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
11 |
12 | FILE_TESTDATA_WEATHERIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
13 |
14 |
15 | @pytest.fixture
16 | def provider(sample_import_1_json, config_eos):
17 | """Fixture to create a WeatherProvider instance."""
18 | settings = {
19 | "weather": {
20 | "provider": "WeatherImport",
21 | "provider_settings": {
22 | "import_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
23 | "import_json": json.dumps(sample_import_1_json),
24 | },
25 | }
26 | }
27 | config_eos.merge_settings_from_dict(settings)
28 | provider = WeatherImport()
29 | assert provider.enabled() == True
30 | return provider
31 |
32 |
33 | @pytest.fixture
34 | def sample_import_1_json():
35 | """Fixture that returns sample forecast data report."""
36 | with FILE_TESTDATA_WEATHERIMPORT_1_JSON.open("r", encoding="utf-8", newline=None) as f_res:
37 | input_data = json.load(f_res)
38 | return input_data
39 |
40 |
41 | # ------------------------------------------------
42 | # General forecast
43 | # ------------------------------------------------
44 |
45 |
46 | def test_singleton_instance(provider):
47 | """Test that WeatherForecast behaves as a singleton."""
48 | another_instance = WeatherImport()
49 | assert provider is another_instance
50 |
51 |
52 | def test_invalid_provider(provider, config_eos, monkeypatch):
53 | """Test requesting an unsupported provider."""
54 | settings = {
55 | "weather": {
56 | "provider": "",
57 | "provider_settings": {
58 | "import_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
59 | },
60 | }
61 | }
62 | with pytest.raises(ValueError, match="not a valid weather provider"):
63 | config_eos.merge_settings_from_dict(settings)
64 |
65 |
66 | # ------------------------------------------------
67 | # Import
68 | # ------------------------------------------------
69 |
70 |
71 | @pytest.mark.parametrize(
72 | "start_datetime, from_file",
73 | [
74 | ("2024-11-10 00:00:00", True), # No DST in Germany
75 | ("2024-08-10 00:00:00", True), # DST in Germany
76 | ("2024-03-31 00:00:00", True), # DST change in Germany (23 hours/ day)
77 | ("2024-10-27 00:00:00", True), # DST change in Germany (25 hours/ day)
78 | ("2024-11-10 00:00:00", False), # No DST in Germany
79 | ("2024-08-10 00:00:00", False), # DST in Germany
80 | ("2024-03-31 00:00:00", False), # DST change in Germany (23 hours/ day)
81 | ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
82 | ],
83 | )
84 | def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
85 | """Test fetching forecast from Import."""
86 | ems_eos = get_ems()
87 | ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
88 | if from_file:
89 | config_eos.weather.provider_settings.import_json = None
90 | assert config_eos.weather.provider_settings.import_json is None
91 | else:
92 | config_eos.weather.provider_settings.import_file_path = None
93 | assert config_eos.weather.provider_settings.import_file_path is None
94 | provider.clear()
95 |
96 | # Call the method
97 | provider.update_data()
98 |
99 | # Assert: Verify the result is as expected
100 | assert provider.start_datetime is not None
101 | assert provider.total_hours is not None
102 | assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
103 | values = sample_import_1_json["weather_temp_air"]
104 | value_datetime_mapping = provider.import_datetimes(ems_eos.start_datetime, len(values))
105 | for i, mapping in enumerate(value_datetime_mapping):
106 | assert i < len(provider.records)
107 | expected_datetime, expected_value_index = mapping
108 | expected_value = values[expected_value_index]
109 | result_datetime = provider.records[i].date_time
110 | result_value = provider.records[i]["weather_temp_air"]
111 |
112 | # print(f"{i}: Expected: {expected_datetime}:{expected_value}")
113 | # print(f"{i}: Result: {result_datetime}:{result_value}")
114 | assert compare_datetimes(result_datetime, expected_datetime).equal
115 | assert result_value == expected_value
116 |
--------------------------------------------------------------------------------
/tests/testdata/eosserver_config_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "elecprice": {
3 | "charges_kwh": 0.21,
4 | "provider": "ElecPriceImport"
5 | },
6 | "general": {
7 | "latitude": 52.5,
8 | "longitude": 13.4
9 | },
10 | "prediction": {
11 | "historic_hours": 48,
12 | "hours": 48
13 | },
14 | "load": {
15 | "provider": "LoadImport",
16 | "provider_settings": {
17 | "loadakkudoktor_year_energy": 20000
18 | }
19 | },
20 | "optimization": {
21 | "hours": 48
22 | },
23 | "pvforecast": {
24 | "planes": [
25 | {
26 | "peakpower": 5.0,
27 | "surface_azimuth": 170,
28 | "surface_tilt": 7,
29 | "userhorizon": [
30 | 20,
31 | 27,
32 | 22,
33 | 20
34 | ],
35 | "inverter_paco": 10000
36 | },
37 | {
38 | "peakpower": 4.8,
39 | "surface_azimuth": 90,
40 | "surface_tilt": 7,
41 | "userhorizon": [
42 | 30,
43 | 30,
44 | 30,
45 | 50
46 | ],
47 | "inverter_paco": 10000
48 | },
49 | {
50 | "peakpower": 1.4,
51 | "surface_azimuth": 140,
52 | "surface_tilt": 60,
53 | "userhorizon": [
54 | 60,
55 | 30,
56 | 0,
57 | 30
58 | ],
59 | "inverter_paco": 2000
60 | },
61 | {
62 | "peakpower": 1.6,
63 | "surface_azimuth": 185,
64 | "surface_tilt": 45,
65 | "userhorizon": [
66 | 45,
67 | 25,
68 | 30,
69 | 60
70 | ],
71 | "inverter_paco": 1400
72 | }
73 | ],
74 | "provider": "PVForecastImport"
75 | },
76 | "server": {
77 | "startup_eosdash": true,
78 | "host": "127.0.0.1",
79 | "port": 8503,
80 | "eosdash_host": "127.0.0.1",
81 | "eosdash_port": 8504
82 | },
83 | "weather": {
84 | "provider": "WeatherImport"
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/tests/testdata/import_input_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "load0_mean": [
3 | 676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68, 995.00,
4 | 1053.07, 1063.91, 1320.56, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12,
5 | 1178.71, 1050.98, 988.56, 912.38, 704.61, 516.37, 868.05, 694.34, 608.79, 556.31,
6 | 488.89, 506.91, 804.89, 1141.98, 1056.97, 992.46, 1155.99, 827.01, 1257.98, 1232.67,
7 | 871.26, 860.88, 1158.03, 1222.72, 1221.04, 949.99, 987.01, 733.99, 592.97
8 | ],
9 | "elecprice_marketprice_wh": [
10 | 0.0003384, 0.0003318, 0.0003284, 0.0003283, 0.0003289, 0.0003334, 0.0003290,
11 | 0.0003302, 0.0003042, 0.0002430, 0.0002280, 0.0002212, 0.0002093, 0.0001879,
12 | 0.0001838, 0.0002004, 0.0002198, 0.0002270, 0.0002997, 0.0003195, 0.0003081,
13 | 0.0002969, 0.0002921, 0.0002780, 0.0003384, 0.0003318, 0.0003284, 0.0003283,
14 | 0.0003289, 0.0003334, 0.0003290, 0.0003302, 0.0003042, 0.0002430, 0.0002280,
15 | 0.0002212, 0.0002093, 0.0001879, 0.0001838, 0.0002004, 0.0002198, 0.0002270,
16 | 0.0002997, 0.0003195, 0.0003081, 0.0002969, 0.0002921, 0.0002780
17 | ],
18 | "pvforecast_ac_power": [
19 | 0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74, 1161.69,
20 | 6018.82, 5519.07, 3969.88, 3017.96, 1943.07, 1007.17, 319.67, 7.88, 0, 0, 0, 0,
21 | 0, 0, 0, 0, 0, 5.04, 335.59, 705.32, 1121.12, 1604.79, 2157.38, 1433.25, 5718.49,
22 | 4553.96, 3027.55, 2574.46, 1720.4, 963.4, 383.3, 0, 0, 0
23 | ],
24 | "weather_temp_air": [
25 | 18.3, 17.8, 16.9, 16.2, 15.6, 15.1, 14.6, 14.2, 14.3, 14.8, 15.7, 16.7,
26 | 17.4, 18.0, 18.6, 19.2, 19.1, 18.7, 18.5, 17.7, 16.2, 14.6, 13.6, 13.0,
27 | 12.6, 12.2, 11.7, 11.6, 11.3, 11.0, 10.7, 10.2, 11.4, 14.4, 16.4, 18.3,
28 | 19.5, 20.7, 21.9, 22.7, 23.1, 23.1, 22.8, 21.8, 20.2, 19.1, 18.0, 17.4
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/tests/testdata/optimize_input_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "ems": {
3 | "preis_euro_pro_wh_akku": 0.0001,
4 | "einspeiseverguetung_euro_pro_wh": [
5 | 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
6 | 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
7 | 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
8 | 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
9 | 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
10 | 0.00007, 0.00007, 0.00007
11 | ],
12 | "gesamtlast": [
13 | 676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68, 995.00,
14 | 1053.07, 1063.91, 1320.56, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12,
15 | 1178.71, 1050.98, 988.56, 912.38, 704.61, 516.37, 868.05, 694.34, 608.79, 556.31,
16 | 488.89, 506.91, 804.89, 1141.98, 1056.97, 992.46, 1155.99, 827.01, 1257.98, 1232.67,
17 | 871.26, 860.88, 1158.03, 1222.72, 1221.04, 949.99, 987.01, 733.99, 592.97
18 | ],
19 | "pv_prognose_wh": [
20 | 0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74, 1161.69,
21 | 6018.82, 5519.07, 3969.88, 3017.96, 1943.07, 1007.17, 319.67, 7.88, 0, 0, 0, 0,
22 | 0, 0, 0, 0, 0, 5.04, 335.59, 705.32, 1121.12, 1604.79, 2157.38, 1433.25, 5718.49,
23 | 4553.96, 3027.55, 2574.46, 1720.4, 963.4, 383.3, 0, 0, 0
24 | ],
25 | "strompreis_euro_pro_wh": [
26 | 0.0003384, 0.0003318, 0.0003284, 0.0003283, 0.0003289, 0.0003334, 0.0003290,
27 | 0.0003302, 0.0003042, 0.0002430, 0.0002280, 0.0002212, 0.0002093, 0.0001879,
28 | 0.0001838, 0.0002004, 0.0002198, 0.0002270, 0.0002997, 0.0003195, 0.0003081,
29 | 0.0002969, 0.0002921, 0.0002780, 0.0003384, 0.0003318, 0.0003284, 0.0003283,
30 | 0.0003289, 0.0003334, 0.0003290, 0.0003302, 0.0003042, 0.0002430, 0.0002280,
31 | 0.0002212, 0.0002093, 0.0001879, 0.0001838, 0.0002004, 0.0002198, 0.0002270,
32 | 0.0002997, 0.0003195, 0.0003081, 0.0002969, 0.0002921, 0.0002780
33 | ]
34 | },
35 | "pv_akku": {
36 | "device_id": "battery1",
37 | "capacity_wh": 26400,
38 | "max_charge_power_w": 5000,
39 | "initial_soc_percentage": 80,
40 | "min_soc_percentage": 15
41 | },
42 | "inverter": {
43 | "device_id": "inverter1",
44 | "max_power_wh": 10000,
45 | "battery_id": "battery1"
46 | },
47 | "eauto": {
48 | "device_id": "ev1",
49 | "capacity_wh": 60000,
50 | "charging_efficiency": 0.95,
51 | "discharging_efficiency": 1.0,
52 | "max_charge_power_w": 11040,
53 | "initial_soc_percentage": 54,
54 | "min_soc_percentage": 0
55 | },
56 | "temperature_forecast": [
57 | 18.3, 17.8, 16.9, 16.2, 15.6, 15.1, 14.6, 14.2, 14.3, 14.8, 15.7, 16.7, 17.4,
58 | 18.0, 18.6, 19.2, 19.1, 18.7, 18.5, 17.7, 16.2, 14.6, 13.6, 13.0, 12.6, 12.2,
59 | 11.7, 11.6, 11.3, 11.0, 10.7, 10.2, 11.4, 14.4, 16.4, 18.3, 19.5, 20.7, 21.9,
60 | 22.7, 23.1, 23.1, 22.8, 21.8, 20.2, 19.1, 18.0, 17.4
61 | ],
62 | "start_solution": [
63 | 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1,
64 | 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0,
65 | 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1,
66 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
67 | ]
68 | }
69 |
70 |
--------------------------------------------------------------------------------
/tests/testdata/optimize_input_2.json:
--------------------------------------------------------------------------------
1 | {
2 | "ems": {
3 | "preis_euro_pro_wh_akku": 0.0,
4 | "einspeiseverguetung_euro_pro_wh": 0.00007,
5 | "gesamtlast": [
6 | 676.71,
7 | 876.19,
8 | 527.13,
9 | 468.88,
10 | 531.38,
11 | 517.95,
12 | 483.15,
13 | 472.28,
14 | 1011.68,
15 | 995.0,
16 | 1053.07,
17 | 1063.91,
18 | 1320.56,
19 | 1132.03,
20 | 1163.67,
21 | 1176.82,
22 | 1216.22,
23 | 1103.78,
24 | 1129.12,
25 | 1178.71,
26 | 1050.98,
27 | 988.56,
28 | 912.38,
29 | 704.61,
30 | 516.37,
31 | 868.05,
32 | 694.34,
33 | 608.79,
34 | 556.31,
35 | 488.89,
36 | 506.91,
37 | 804.89,
38 | 1141.98,
39 | 1056.97,
40 | 992.46,
41 | 1155.99,
42 | 827.01,
43 | 1257.98,
44 | 1232.67,
45 | 871.26,
46 | 860.88,
47 | 1158.03,
48 | 1222.72,
49 | 1221.04,
50 | 949.99,
51 | 987.01,
52 | 733.99,
53 | 592.97
54 | ],
55 | "pv_prognose_wh": [
56 | 0,
57 | 0,
58 | 0,
59 | 0,
60 | 0,
61 | 0,
62 | 0,
63 | 8.05,
64 | 352.91,
65 | 728.51,
66 | 930.28,
67 | 1043.25,
68 | 1106.74,
69 | 1161.69,
70 | 6018.82,
71 | 5519.07,
72 | 3969.88,
73 | 3017.96,
74 | 1943.07,
75 | 1007.17,
76 | 319.67,
77 | 7.88,
78 | 0,
79 | 0,
80 | 0,
81 | 0,
82 | 0,
83 | 0,
84 | 0,
85 | 0,
86 | 0,
87 | 5.04,
88 | 335.59,
89 | 705.32,
90 | 1121.12,
91 | 1604.79,
92 | 2157.38,
93 | 1433.25,
94 | 5718.49,
95 | 4553.96,
96 | 3027.55,
97 | 2574.46,
98 | 1720.4,
99 | 963.4,
100 | 383.3,
101 | 0,
102 | 0,
103 | 0
104 | ],
105 | "strompreis_euro_pro_wh": [
106 | 0.0003384,
107 | 0.0003318,
108 | 0.0003284,
109 | 0.0003283,
110 | 0.0003289,
111 | 0.0003334,
112 | 0.000329,
113 | 0.0003302,
114 | 0.0003042,
115 | 0.000243,
116 | 0.000228,
117 | 0.0002212,
118 | 0.0002093,
119 | 0.0001879,
120 | 0.0001838,
121 | 0.0002004,
122 | 0.0002198,
123 | 0.000227,
124 | 0.0002997,
125 | 0.0003195,
126 | 0.0003081,
127 | 0.0002969,
128 | 0.0002921,
129 | 0.000278,
130 | 0.0003384,
131 | 0.0003318,
132 | 0.0003284,
133 | 0.0003283,
134 | 0.0003289,
135 | 0.0003334,
136 | 0.000329,
137 | 0.0003302,
138 | 0.0003042,
139 | 0.000243,
140 | 0.000228,
141 | 0.0002212,
142 | 0.0002093,
143 | 0.0001879,
144 | 0.0001838,
145 | 0.0002004,
146 | 0.0002198,
147 | 0.000227,
148 | 0.0002997,
149 | 0.0003195,
150 | 0.0003081,
151 | 0.0002969,
152 | 0.0002921,
153 | 0.000278
154 | ]
155 | },
156 | "pv_akku": {
157 | "device_id": "battery1",
158 | "capacity_wh": 26400,
159 | "initial_soc_percentage": 80,
160 | "min_soc_percentage": 0
161 | },
162 | "inverter": {
163 | "device_id": "inverter1",
164 | "max_power_wh": 10000,
165 | "battery_id": "battery1"
166 | },
167 | "eauto": {
168 | "device_id": "ev1",
169 | "capacity_wh": 60000,
170 | "charging_efficiency": 0.95,
171 | "max_charge_power_w": 11040,
172 | "initial_soc_percentage": 5,
173 | "min_soc_percentage": 80
174 | },
175 | "dishwasher": {
176 | "device_id": "dishwasher1",
177 | "consumption_wh": 5000,
178 | "duration_h": 2
179 | },
180 | "temperature_forecast": [
181 | 18.3,
182 | 17.8,
183 | 16.9,
184 | 16.2,
185 | 15.6,
186 | 15.1,
187 | 14.6,
188 | 14.2,
189 | 14.3,
190 | 14.8,
191 | 15.7,
192 | 16.7,
193 | 17.4,
194 | 18.0,
195 | 18.6,
196 | 19.2,
197 | 19.1,
198 | 18.7,
199 | 18.5,
200 | 17.7,
201 | 16.2,
202 | 14.6,
203 | 13.6,
204 | 13.0,
205 | 12.6,
206 | 12.2,
207 | 11.7,
208 | 11.6,
209 | 11.3,
210 | 11.0,
211 | 10.7,
212 | 10.2,
213 | 11.4,
214 | 14.4,
215 | 16.4,
216 | 18.3,
217 | 19.5,
218 | 20.7,
219 | 21.9,
220 | 22.7,
221 | 23.1,
222 | 23.1,
223 | 22.8,
224 | 21.8,
225 | 20.2,
226 | 19.1,
227 | 18.0,
228 | 17.4
229 | ],
230 | "start_solution": null
231 | }
232 |
--------------------------------------------------------------------------------
/tests/testdata/test_example_report.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/98738a16c9fb16cd43dc1d8ecab38fae2ff23fa1/tests/testdata/test_example_report.pdf
--------------------------------------------------------------------------------