├── .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 = """![Logo](/eosdash/assets/logo.png) 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 --------------------------------------------------------------------------------