Compare commits
2 Commits
dependabot
...
NormannK-p
Author | SHA1 | Date | |
---|---|---|---|
|
be2ff5ea3d | ||
|
54f78fbc49 |
@@ -1,8 +1,8 @@
|
||||
.git/
|
||||
.github/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.egg-info/
|
||||
eos-data/
|
||||
mariadb-data/
|
||||
test_data/
|
||||
.dockerignore
|
||||
.env
|
||||
.gitignore
|
||||
@@ -12,4 +12,4 @@ LICENSE
|
||||
Makefile
|
||||
NOTICE
|
||||
README.md
|
||||
.venv/
|
||||
.venv
|
||||
|
4
.env
@@ -1,5 +1,5 @@
|
||||
EOS_VERSION=main
|
||||
EOS_SERVER__PORT=8503
|
||||
EOS_SERVER__EOSDASH_PORT=8504
|
||||
EOS_PORT=8503
|
||||
EOSDASH_PORT=8504
|
||||
|
||||
PYTHON_VERSION=3.12.6
|
||||
|
5
.github/workflows/docker-build.yml
vendored
@@ -7,11 +7,13 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'feature/config-overhaul'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
- 'main'
|
||||
- 'feature/config-overhaul'
|
||||
|
||||
env:
|
||||
DOCKERHUB_REPO: akkudoktor/eos
|
||||
@@ -195,7 +197,6 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
labels: |
|
||||
org.opencontainers.image.licenses=${{ env.EOS_LICENSE }}
|
||||
annotations: |
|
||||
|
35
.github/workflows/stale.yml
vendored
@@ -1,35 +0,0 @@
|
||||
name: "Close stale pull requests/issues"
|
||||
on:
|
||||
schedule:
|
||||
- cron: "16 00 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Find Stale issues and PRs
|
||||
runs-on: ubuntu-22.04
|
||||
if: github.repository == 'Akkudoktor-EOS/EOS'
|
||||
permissions:
|
||||
pull-requests: write # to comment on stale pull requests
|
||||
issues: write # to comment on stale issues
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
with:
|
||||
stale-pr-message: 'This pull request has been marked as stale because it has been open (more
|
||||
than) 90 days with no activity. Remove the stale label or add a comment saying that you
|
||||
would like to have the label removed otherwise this pull request will automatically be
|
||||
closed in 30 days. Note, that you can always re-open a closed pull request at any time.'
|
||||
stale-issue-message: 'This issue has been marked as stale because it has been open (more
|
||||
than) 90 days with no activity. Remove the stale label or add a comment saying that you
|
||||
would like to have the label removed otherwise this issue will automatically be closed in
|
||||
30 days. Note, that you can always re-open a closed issue at any time.'
|
||||
days-before-stale: 90
|
||||
days-before-close: 30
|
||||
stale-issue-label: 'stale'
|
||||
stale-pr-label: 'stale'
|
||||
exempt-pr-labels: 'in progress'
|
||||
exempt-issue-labels: 'feature request, enhancement'
|
||||
operations-per-run: 400
|
5
.gitignore
vendored
@@ -179,7 +179,7 @@ cython_debug/
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
#.idea/
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
@@ -260,6 +260,3 @@ tests/testdata/new_optimize_result*
|
||||
tests/testdata/openapi-new.json
|
||||
tests/testdata/openapi-new.md
|
||||
tests/testdata/config-new.md
|
||||
|
||||
# FastHTML session key
|
||||
.sesskey
|
||||
|
35
.gitlint
@@ -1,35 +0,0 @@
|
||||
[general]
|
||||
# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
|
||||
verbosity = 3
|
||||
|
||||
regex-style-search=true
|
||||
|
||||
# Ignore rules, reference them by id or name (comma-separated)
|
||||
ignore=title-trailing-punctuation, T3
|
||||
|
||||
# Enable specific community contributed rules
|
||||
contrib=contrib-title-conventional-commits,CC1
|
||||
|
||||
# Set the extra-path where gitlint will search for user defined rules
|
||||
extra-path=scripts/gitlint
|
||||
|
||||
[title-max-length]
|
||||
line-length=80
|
||||
|
||||
[title-min-length]
|
||||
min-length=5
|
||||
|
||||
[ignore-by-title]
|
||||
# Match commit titles starting with "Release"
|
||||
regex=^Release(.*)
|
||||
ignore=title-max-length,body-min-length
|
||||
|
||||
[ignore-by-body]
|
||||
# Match commits message bodies that have a line that contains 'release'
|
||||
regex=(.*)release(.*)
|
||||
ignore=all
|
||||
|
||||
[ignore-by-author-name]
|
||||
# Match commits by author name (e.g. ignore dependabot commits)
|
||||
regex=dependabot
|
||||
ignore=all
|
@@ -12,12 +12,12 @@ repos:
|
||||
- id: check-merge-conflict
|
||||
exclude: '\.rst$' # Exclude .rst files
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 6.0.0
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.6
|
||||
rev: v0.6.8
|
||||
hooks:
|
||||
# Run the linter and fix simple issues automatically
|
||||
- id: ruff
|
||||
@@ -25,7 +25,7 @@ repos:
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: 'v1.15.0'
|
||||
rev: 'v1.13.0'
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
@@ -34,7 +34,7 @@ repos:
|
||||
- "numpy==2.1.3"
|
||||
pass_filenames: false
|
||||
- repo: https://github.com/jackdewinter/pymarkdown
|
||||
rev: v0.9.29
|
||||
rev: main
|
||||
hooks:
|
||||
- id: pymarkdown
|
||||
files: ^docs/
|
||||
@@ -42,7 +42,3 @@ repos:
|
||||
args:
|
||||
- --config=docs/pymarkdown.json
|
||||
- scan
|
||||
- repo: https://github.com/jorisroovers/gitlint
|
||||
rev: v0.19.1
|
||||
hooks:
|
||||
- id: gitlint
|
||||
|
@@ -21,8 +21,8 @@ There are just too many possibilities and the project would drown in tickets oth
|
||||
## Code Contributions
|
||||
|
||||
We welcome code contributions and bug fixes via [Pull Requests](https://github.com/Akkudoktor-EOS/EOS/pulls).
|
||||
To make collaboration easier, we require pull requests to pass code style, unit tests, and commit
|
||||
message style checks.
|
||||
To make collaboration easier, we require pull requests to pass code style and unit tests.
|
||||
|
||||
|
||||
### Setup development environment
|
||||
|
||||
@@ -60,7 +60,6 @@ To run formatting automatically before every commit:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
pre-commit install --hook-type commit-msg
|
||||
```
|
||||
|
||||
Or run them manually:
|
||||
@@ -76,8 +75,3 @@ Use `pytest` to run tests locally:
|
||||
```bash
|
||||
python -m pytest -vs --cov src --cov-report term-missing tests/
|
||||
```
|
||||
|
||||
### Commit message style
|
||||
|
||||
Our commit message checks use [`gitlint`](https://github.com/jorisroovers/gitlint). The checks
|
||||
enforce the [`Conventional Commits`](https://www.conventionalcommits.org) commit message style.
|
||||
|
17
Dockerfile
@@ -1,9 +1,10 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG PYTHON_VERSION=3.12.7
|
||||
FROM python:${PYTHON_VERSION}-slim
|
||||
|
||||
LABEL source="https://github.com/Akkudoktor-EOS/EOS"
|
||||
|
||||
ENV VIRTUAL_ENV="/opt/venv"
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
ENV MPLCONFIGDIR="/tmp/mplconfigdir"
|
||||
ENV EOS_DIR="/opt/eos"
|
||||
ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
|
||||
@@ -35,25 +36,13 @@ RUN mkdir -p src && pip install -e .
|
||||
|
||||
COPY src src
|
||||
|
||||
# Create minimal default configuration for Docker to fix EOSDash accessibility (#629)
|
||||
# This ensures EOSDash binds to 0.0.0.0 instead of 127.0.0.1 in containers
|
||||
RUN echo '{\n\
|
||||
"server": {\n\
|
||||
"host": "0.0.0.0",\n\
|
||||
"port": 8503,\n\
|
||||
"startup_eosdash": true,\n\
|
||||
"eosdash_host": "0.0.0.0",\n\
|
||||
"eosdash_port": 8504\n\
|
||||
}\n\
|
||||
}' > "${EOS_CONFIG_DIR}/EOS.config.json" \
|
||||
&& chown eos:eos "${EOS_CONFIG_DIR}/EOS.config.json"
|
||||
|
||||
USER eos
|
||||
ENTRYPOINT []
|
||||
|
||||
EXPOSE 8503
|
||||
EXPOSE 8504
|
||||
|
||||
ENV server_eosdash_host=0.0.0.0
|
||||
CMD ["python", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"]
|
||||
|
||||
VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"]
|
||||
|
38
Makefile
@@ -1,5 +1,5 @@
|
||||
# Define the targets
|
||||
.PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format gitlint mypy run run-dev
|
||||
.PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format mypy run run-dev
|
||||
|
||||
# Default target
|
||||
all: help
|
||||
@@ -11,7 +11,6 @@ help:
|
||||
@echo " pip - Install dependencies from requirements.txt."
|
||||
@echo " pip-dev - Install dependencies from requirements-dev.txt."
|
||||
@echo " format - Format source code."
|
||||
@echo " gitlint - Lint last commit message."
|
||||
@echo " mypy - Run mypy."
|
||||
@echo " install - Install EOS in editable form (development mode) into virtual environment."
|
||||
@echo " docker-run - Run entire setup on docker"
|
||||
@@ -20,13 +19,8 @@ help:
|
||||
@echo " read-docs - Read HTML documentation in your browser."
|
||||
@echo " gen-docs - Generate openapi.json and docs/_generated/*."
|
||||
@echo " clean-docs - Remove generated documentation."
|
||||
@echo " run - Run EOS production server in virtual environment."
|
||||
@echo " run-dev - Run EOS development server in virtual environment (automatically reloads)."
|
||||
@echo " run-dash - Run EOSdash production server in virtual environment."
|
||||
@echo " run-dash-dev - Run EOSdash development server in virtual environment (automatically reloads)."
|
||||
@echo " test - Run tests."
|
||||
@echo " test-full - Run tests with full optimization."
|
||||
@echo " test-ci - Run tests as CI does. No user config file allowed."
|
||||
@echo " run - Run EOS production server in the virtual environment."
|
||||
@echo " run-dev - Run EOS development server in the virtual environment (automatically reloads)."
|
||||
@echo " dist - Create distribution (in dist/)."
|
||||
@echo " clean - Remove generated documentation, distribution and virtual environment."
|
||||
|
||||
@@ -76,11 +70,6 @@ read-docs: docs
|
||||
@echo "Read the documentation in your browser"
|
||||
.venv/bin/python -m webbrowser build/docs/html/index.html
|
||||
|
||||
# Clean Python bytecode
|
||||
clean-bytecode:
|
||||
find . -type d -name "__pycache__" -exec rm -r {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
|
||||
# Clean target to remove generated documentation and documentation artefacts
|
||||
clean-docs:
|
||||
@echo "Searching and deleting all '_autosum' directories in docs..."
|
||||
@@ -96,19 +85,11 @@ clean: clean-docs
|
||||
|
||||
run:
|
||||
@echo "Starting EOS production server, please wait..."
|
||||
.venv/bin/python -m akkudoktoreos.server.eos
|
||||
.venv/bin/python src/akkudoktoreos/server/eos.py
|
||||
|
||||
run-dev:
|
||||
@echo "Starting EOS development server, please wait..."
|
||||
.venv/bin/python -m akkudoktoreos.server.eos --host localhost --port 8503 --reload true
|
||||
|
||||
run-dash:
|
||||
@echo "Starting EOSdash production server, please wait..."
|
||||
.venv/bin/python -m akkudoktoreos.server.eosdash
|
||||
|
||||
run-dash-dev:
|
||||
@echo "Starting EOSdash development server, please wait..."
|
||||
.venv/bin/python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --reload true
|
||||
.venv/bin/python src/akkudoktoreos/server/eos.py --host localhost --port 8503 --reload true
|
||||
|
||||
# Target to setup tests.
|
||||
test-setup: pip-dev
|
||||
@@ -119,11 +100,6 @@ test:
|
||||
@echo "Running tests..."
|
||||
.venv/bin/pytest -vs --cov src --cov-report term-missing
|
||||
|
||||
# Target to run tests as done by CI on Github.
|
||||
test-ci:
|
||||
@echo "Running tests as CI..."
|
||||
.venv/bin/pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing
|
||||
|
||||
# Target to run all tests.
|
||||
test-full:
|
||||
@echo "Running all tests..."
|
||||
@@ -133,10 +109,6 @@ test-full:
|
||||
format:
|
||||
.venv/bin/pre-commit run --all-files
|
||||
|
||||
# Target to trigger gitlint using pre-commit for the last commit message
|
||||
gitlint:
|
||||
.venv/bin/pre-commit run gitlint --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
|
||||
|
||||
# Target to format code.
|
||||
mypy:
|
||||
.venv/bin/mypy
|
||||
|
@@ -20,7 +20,7 @@ Other architectures (e.g. armv6, armv7) are unsupported for now, because a multi
|
||||
|
||||
## Installation
|
||||
|
||||
The project requires Python 3.11 or newer. Docker images (amd64/aarch64) can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos).
|
||||
Docker images (amd64/aarch64) can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos).
|
||||
|
||||
Following sections describe how to locally start the EOS server on `http://localhost:8503`.
|
||||
|
||||
@@ -40,9 +40,8 @@ Windows:
|
||||
|
||||
```cmd
|
||||
python -m venv .venv
|
||||
.venv\Scripts\Activate
|
||||
.venv\Scripts\pip install -r requirements.txt
|
||||
.venv\Scripts\pip install -e .
|
||||
.venv\Scripts\pip install -r requirements.txt
|
||||
.venv\Scripts\pip install -e .
|
||||
```
|
||||
|
||||
Finally, start the EOS server to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`):
|
||||
@@ -67,8 +66,6 @@ Start EOS with following command to access it at `http://localhost:8503` (API do
|
||||
docker compose up
|
||||
```
|
||||
|
||||
If you are running the EOS container on a system hosting multiple services, such as a Synology NAS, and want to allow external network access to EOS, please ensure that the default exported ports (8503, 8504) are available on the host. On Synology systems, these ports might already be in use (refer to [this guide](https://kb.synology.com/en-me/DSM/tutorial/What_network_ports_are_used_by_Synology_services)). If the ports are occupied, you will need to reconfigure the exported ports accordingly.
|
||||
|
||||
## Configuration
|
||||
|
||||
This project uses the `EOS.config.json` file to manage configuration settings.
|
||||
|
@@ -11,35 +11,14 @@ services:
|
||||
dockerfile: "Dockerfile"
|
||||
args:
|
||||
PYTHON_VERSION: "${PYTHON_VERSION}"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- EOS_CONFIG_DIR=config
|
||||
- latitude=52.2
|
||||
- longitude=13.4
|
||||
- elecprice_provider=ElecPriceAkkudoktor
|
||||
- elecprice_charges_kwh=0.21
|
||||
- EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
|
||||
- EOS_PREDICTION__LATITUDE=52.2
|
||||
- EOS_PREDICTION__LONGITUDE=13.4
|
||||
- EOS_ELECPRICE__PROVIDER=ElecPriceAkkudoktor
|
||||
- EOS_ELECPRICE__CHARGES_KWH=0.21
|
||||
ports:
|
||||
# Configure what ports to expose on host
|
||||
- "${EOS_SERVER__PORT}:8503"
|
||||
- "${EOS_SERVER__EOSDASH_PORT}:8504"
|
||||
|
||||
# Volume mount configuration (optional)
|
||||
# IMPORTANT: When mounting local directories, the default config won't be available.
|
||||
# You must create an EOS.config.json file in your local config directory with:
|
||||
# {
|
||||
# "server": {
|
||||
# "host": "0.0.0.0", # Required for Docker container accessibility
|
||||
# "port": 8503,
|
||||
# "startup_eosdash": true,
|
||||
# "eosdash_host": "0.0.0.0", # Required for Docker container accessibility
|
||||
# "eosdash_port": 8504
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Example volume mounts (uncomment to use):
|
||||
# volumes:
|
||||
# - ./config:/opt/eos/config # Mount local config directory
|
||||
# - ./cache:/opt/eos/cache # Mount local cache directory
|
||||
# - ./output:/opt/eos/output # Mount local output directory
|
||||
- "${EOS_PORT}:8503"
|
||||
- "${EOSDASH_PORT}:8504"
|
||||
|
@@ -63,7 +63,7 @@ Args:
|
||||
year_energy (float): Yearly energy consumption in Wh.
|
||||
|
||||
Note:
|
||||
Set LoadAkkudoktor as provider, then update data with
|
||||
Set LoadAkkudoktor as load_provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=load_mean' instead.
|
||||
@@ -91,8 +91,6 @@ Fastapi Optimize
|
||||
|
||||
- `start_hour` (query, optional): Defaults to current hour of the day.
|
||||
|
||||
- `ngen` (query, optional): No description provided.
|
||||
|
||||
**Request Body**:
|
||||
|
||||
- `application/json`: {
|
||||
@@ -123,7 +121,7 @@ If no forecast values are available the missing ones at the start of the series
|
||||
filled with the first available forecast value.
|
||||
|
||||
Note:
|
||||
Set PVForecastAkkudoktor as provider, then update data with
|
||||
Set PVForecastAkkudoktor as pvforecast_provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=pvforecast_ac_power' and
|
||||
@@ -153,7 +151,7 @@ Note:
|
||||
Electricity price charges are added.
|
||||
|
||||
Note:
|
||||
Set ElecPriceAkkudoktor as provider, then update data with
|
||||
Set ElecPriceAkkudoktor as elecprice_provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=elecprice_marketprice_wh' or
|
||||
@@ -166,127 +164,6 @@ Note:
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/admin/cache
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_get_v1_admin_cache_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_get_v1_admin_cache_get)
|
||||
|
||||
Fastapi Admin Cache Get
|
||||
|
||||
```
|
||||
Current cache management data.
|
||||
|
||||
Returns:
|
||||
data (dict): The management data.
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/admin/cache/clear
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_clear_post_v1_admin_cache_clear_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_clear_post_v1_admin_cache_clear_post)
|
||||
|
||||
Fastapi Admin Cache Clear Post
|
||||
|
||||
```
|
||||
Clear the cache from expired data.
|
||||
|
||||
Deletes expired cache files.
|
||||
|
||||
Args:
|
||||
clear_all (Optional[bool]): Delete all cached files. Default is False.
|
||||
|
||||
Returns:
|
||||
data (dict): The management data after cleanup.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `clear_all` (query, optional): No description provided.
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/admin/cache/load
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_load_post_v1_admin_cache_load_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_load_post_v1_admin_cache_load_post)
|
||||
|
||||
Fastapi Admin Cache Load Post
|
||||
|
||||
```
|
||||
Load cache management data.
|
||||
|
||||
Returns:
|
||||
data (dict): The management data that was loaded.
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/admin/cache/save
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_save_post_v1_admin_cache_save_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_save_post_v1_admin_cache_save_post)
|
||||
|
||||
Fastapi Admin Cache Save Post
|
||||
|
||||
```
|
||||
Save the current cache management data.
|
||||
|
||||
Returns:
|
||||
data (dict): The management data that was saved.
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/admin/server/restart
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_server_restart_post_v1_admin_server_restart_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_server_restart_post_v1_admin_server_restart_post)
|
||||
|
||||
Fastapi Admin Server Restart Post
|
||||
|
||||
```
|
||||
Restart the server.
|
||||
|
||||
Restart EOS properly by starting a new instance before exiting the old one.
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/admin/server/shutdown
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_server_shutdown_post_v1_admin_server_shutdown_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_server_shutdown_post_v1_admin_server_shutdown_post)
|
||||
|
||||
Fastapi Admin Server Shutdown Post
|
||||
|
||||
```
|
||||
Shutdown the server.
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/config
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_v1_config_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_v1_config_get)
|
||||
@@ -313,11 +190,11 @@ Returns:
|
||||
Fastapi Config Put
|
||||
|
||||
```
|
||||
Update the current config with the provided settings.
|
||||
Write the provided settings into the current settings.
|
||||
|
||||
Note that for any setting value that is None or unset, the configuration will fall back to
|
||||
values from other sources such as environment variables, the EOS configuration file, or default
|
||||
values.
|
||||
The existing settings are completely overwritten. Note that for any setting
|
||||
value that is None, the configuration will fall back to values from other sources such as
|
||||
environment variables, the EOS configuration file, or default values.
|
||||
|
||||
Args:
|
||||
settings (SettingsEOS): The settings to write into the current settings.
|
||||
@@ -326,11 +203,311 @@ Returns:
|
||||
configuration (ConfigEOS): The current configuration after the write.
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
**Parameters**:
|
||||
|
||||
- `application/json`: {
|
||||
"$ref": "#/components/schemas/SettingsEOS"
|
||||
}
|
||||
- `server_eos_host` (query, optional): EOS server IP address.
|
||||
|
||||
- `server_eos_port` (query, optional): EOS server IP port number.
|
||||
|
||||
- `server_eos_verbose` (query, optional): Enable debug output
|
||||
|
||||
- `server_eos_startup_eosdash` (query, optional): EOS server to start EOSdash server.
|
||||
|
||||
- `server_eosdash_host` (query, optional): EOSdash server IP address.
|
||||
|
||||
- `server_eosdash_port` (query, optional): EOSdash server IP port number.
|
||||
|
||||
- `weatherimport_file_path` (query, optional): Path to the file to import weather data from.
|
||||
|
||||
- `weatherimport_json` (query, optional): JSON string, dictionary of weather forecast value lists.
|
||||
|
||||
- `weather_provider` (query, optional): Weather provider id of provider to be used.
|
||||
|
||||
- `pvforecastimport_file_path` (query, optional): Path to the file to import PV forecast data from.
|
||||
|
||||
- `pvforecastimport_json` (query, optional): JSON string, dictionary of PV forecast value lists.
|
||||
|
||||
- `pvforecast_provider` (query, optional): PVForecast provider id of provider to be used.
|
||||
|
||||
- `pvforecast0_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast0_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast0_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast0_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast0_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast0_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast0_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast0_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast0_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast0_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast0_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast0_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast0_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast0_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast0_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast0_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast1_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast1_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast1_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast1_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast1_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast1_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast1_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast1_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast1_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast1_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast1_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast1_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast1_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast1_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast1_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast1_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast2_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast2_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast2_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast2_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast2_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast2_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast2_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast2_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast2_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast2_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast2_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast2_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast2_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast2_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast2_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast2_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast3_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast3_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast3_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast3_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast3_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast3_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast3_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast3_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast3_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast3_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast3_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast3_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast3_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast3_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast3_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast3_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast4_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast4_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast4_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast4_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast4_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast4_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast4_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast4_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast4_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast4_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast4_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast4_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast4_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast4_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast4_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast4_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast5_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast5_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast5_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast5_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast5_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast5_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast5_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast5_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast5_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast5_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast5_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast5_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast5_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast5_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast5_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast5_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `load_import_file_path` (query, optional): Path to the file to import load data from.
|
||||
|
||||
- `load_import_json` (query, optional): JSON string, dictionary of load forecast value lists.
|
||||
|
||||
- `loadakkudoktor_year_energy` (query, optional): Yearly energy consumption (kWh).
|
||||
|
||||
- `load_provider` (query, optional): Load provider id of provider to be used.
|
||||
|
||||
- `elecpriceimport_file_path` (query, optional): Path to the file to import elecprice data from.
|
||||
|
||||
- `elecpriceimport_json` (query, optional): JSON string, dictionary of electricity price forecast value lists.
|
||||
|
||||
- `elecprice_provider` (query, optional): Electricity price provider id of provider to be used.
|
||||
|
||||
- `elecprice_charges_kwh` (query, optional): Electricity price charges (€/kWh).
|
||||
|
||||
- `prediction_hours` (query, optional): Number of hours into the future for predictions
|
||||
|
||||
- `prediction_historic_hours` (query, optional): Number of hours into the past for historical predictions data
|
||||
|
||||
- `latitude` (query, optional): Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)
|
||||
|
||||
- `longitude` (query, optional): Longitude in decimal degrees, within -180 to 180 (°)
|
||||
|
||||
- `optimization_hours` (query, optional): Number of hours into the future for optimizations.
|
||||
|
||||
- `optimization_penalty` (query, optional): Penalty factor used in optimization.
|
||||
|
||||
- `optimization_ev_available_charge_rates_percent` (query, optional): Charge rates available for the EV in percent of maximum charge.
|
||||
|
||||
- `measurement_load0_name` (query, optional): Name of the load0 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `measurement_load1_name` (query, optional): Name of the load1 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `measurement_load2_name` (query, optional): Name of the load2 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `measurement_load3_name` (query, optional): Name of the load3 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `measurement_load4_name` (query, optional): Name of the load4 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `battery_provider` (query, optional): Id of Battery simulation provider.
|
||||
|
||||
- `battery_capacity` (query, optional): Battery capacity [Wh].
|
||||
|
||||
- `battery_initial_soc` (query, optional): Battery initial state of charge [%].
|
||||
|
||||
- `battery_soc_min` (query, optional): Battery minimum state of charge [%].
|
||||
|
||||
- `battery_soc_max` (query, optional): Battery maximum state of charge [%].
|
||||
|
||||
- `battery_charging_efficiency` (query, optional): Battery charging efficiency [%].
|
||||
|
||||
- `battery_discharging_efficiency` (query, optional): Battery discharging efficiency [%].
|
||||
|
||||
- `battery_max_charging_power` (query, optional): Battery maximum charge power [W].
|
||||
|
||||
- `bev_provider` (query, optional): Id of Battery Electric Vehicle simulation provider.
|
||||
|
||||
- `bev_capacity` (query, optional): Battery Electric Vehicle capacity [Wh].
|
||||
|
||||
- `bev_initial_soc` (query, optional): Battery Electric Vehicle initial state of charge [%].
|
||||
|
||||
- `bev_soc_max` (query, optional): Battery Electric Vehicle maximum state of charge [%].
|
||||
|
||||
- `bev_charging_efficiency` (query, optional): Battery Electric Vehicle charging efficiency [%].
|
||||
|
||||
- `bev_discharging_efficiency` (query, optional): Battery Electric Vehicle discharging efficiency [%].
|
||||
|
||||
- `bev_max_charging_power` (query, optional): Battery Electric Vehicle maximum charge power [W].
|
||||
|
||||
- `dishwasher_provider` (query, optional): Id of Dish Washer simulation provider.
|
||||
|
||||
- `dishwasher_consumption` (query, optional): Dish Washer energy consumption [Wh].
|
||||
|
||||
- `dishwasher_duration` (query, optional): Dish Washer usage duration [h].
|
||||
|
||||
- `inverter_provider` (query, optional): Id of PV Inverter simulation provider.
|
||||
|
||||
- `inverter_power_max` (query, optional): Inverter maximum power [W].
|
||||
|
||||
- `logging_level_default` (query, optional): EOS default logging level.
|
||||
|
||||
- `data_folder_path` (query, optional): Path to EOS data directory.
|
||||
|
||||
- `data_output_subpath` (query, optional): Sub-path for the EOS output data directory.
|
||||
|
||||
- `data_cache_subpath` (query, optional): Sub-path for the EOS cache data directory.
|
||||
|
||||
**Responses**:
|
||||
|
||||
@@ -340,6 +517,25 @@ Returns:
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/config/file
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_file_get_v1_config_file_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_file_get_v1_config_file_get)
|
||||
|
||||
Fastapi Config File Get
|
||||
|
||||
```
|
||||
Get the settings as defined by the EOS configuration file.
|
||||
|
||||
Returns:
|
||||
settings (SettingsEOS): The settings defined by the EOS configuration file.
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## PUT /v1/config/file
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_file_put_v1_config_file_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_file_put_v1_config_file_put)
|
||||
@@ -359,14 +555,14 @@ Returns:
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/config/reset
|
||||
## POST /v1/config/update
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_reset_post_v1_config_reset_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_reset_post_v1_config_reset_post)
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_update_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_update_post_v1_config_update_post)
|
||||
|
||||
Fastapi Config Reset Post
|
||||
Fastapi Config Update Post
|
||||
|
||||
```
|
||||
Reset the configuration to the EOS configuration file.
|
||||
Update the configuration from the EOS configuration file.
|
||||
|
||||
Returns:
|
||||
configuration (ConfigEOS): The current configuration after update.
|
||||
@@ -378,132 +574,28 @@ Returns:
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/config/{path}
|
||||
## PUT /v1/config/value
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_key_v1_config__path__get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_key_v1_config__path__get)
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_value_put_v1_config_value_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_value_put_v1_config_value_put)
|
||||
|
||||
Fastapi Config Get Key
|
||||
Fastapi Config Value Put
|
||||
|
||||
```
|
||||
Get the value of a nested key or index in the config model.
|
||||
Set the configuration option in the settings.
|
||||
|
||||
Args:
|
||||
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
|
||||
key (str): configuration key
|
||||
value (Any): configuration value
|
||||
|
||||
Returns:
|
||||
value (Any): The value of the selected nested key.
|
||||
configuration (ConfigEOS): The current configuration after the write.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `path` (path, required): The nested path to the configuration key (e.g., general/latitude).
|
||||
- `key` (query, required): configuration key
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## PUT /v1/config/{path}
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_put_key_v1_config__path__put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_put_key_v1_config__path__put)
|
||||
|
||||
Fastapi Config Put Key
|
||||
|
||||
```
|
||||
Update a nested key or index in the config model.
|
||||
|
||||
Args:
|
||||
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
|
||||
value (Any): The new value to assign to the key or index at path.
|
||||
|
||||
Returns:
|
||||
configuration (ConfigEOS): The current configuration after the update.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `path` (path, required): The nested path to the configuration key (e.g., general/latitude).
|
||||
|
||||
**Request Body**:
|
||||
|
||||
- `application/json`: {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "The value to assign to the specified configuration path (can be None).",
|
||||
"title": "Value"
|
||||
}
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/health
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_health_get_v1_health_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_health_get_v1_health_get)
|
||||
|
||||
Fastapi Health Get
|
||||
|
||||
```
|
||||
Health check endpoint to verify that the EOS server is alive.
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/logging/log
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_logging_get_log_v1_logging_log_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_logging_get_log_v1_logging_log_get)
|
||||
|
||||
Fastapi Logging Get Log
|
||||
|
||||
```
|
||||
Get structured log entries from the EOS log file.
|
||||
|
||||
Filters and returns log entries based on the specified query parameters. The log
|
||||
file is expected to contain newline-delimited JSON entries.
|
||||
|
||||
Args:
|
||||
limit (int): Maximum number of entries to return.
|
||||
level (Optional[str]): Filter logs by severity level (e.g., DEBUG, INFO).
|
||||
contains (Optional[str]): Return only logs that include this string in the message.
|
||||
regex (Optional[str]): Return logs that match this regular expression in the message.
|
||||
from_time (Optional[str]): ISO 8601 timestamp to filter logs not older than this.
|
||||
to_time (Optional[str]): ISO 8601 timestamp to filter logs not newer than this.
|
||||
tail (bool): If True, fetch the most recent log entries (like `tail`).
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON list of log entries.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `limit` (query, optional): Maximum number of log entries to return.
|
||||
|
||||
- `level` (query, optional): Filter by log level (e.g., INFO, ERROR).
|
||||
|
||||
- `contains` (query, optional): Filter logs containing this substring.
|
||||
|
||||
- `regex` (query, optional): Filter logs by matching regex in message.
|
||||
|
||||
- `from_time` (query, optional): Start time (ISO format) for filtering logs.
|
||||
|
||||
- `to_time` (query, optional): End time (ISO format) for filtering logs.
|
||||
|
||||
- `tail` (query, optional): If True, returns the most recent lines (tail mode).
|
||||
- `value` (query, required): configuration value
|
||||
|
||||
**Responses**:
|
||||
|
||||
@@ -729,93 +821,6 @@ Merge the measurement of given key and value into EOS measurements at given date
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/prediction/dataframe
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_dataframe_get_v1_prediction_dataframe_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_dataframe_get_v1_prediction_dataframe_get)
|
||||
|
||||
Fastapi Prediction Dataframe Get
|
||||
|
||||
```
|
||||
Get prediction for given key within given date range as series.
|
||||
|
||||
Args:
|
||||
key (str): Prediction key
|
||||
start_datetime (Optional[str]): Starting datetime (inclusive).
|
||||
Defaults to start datetime of latest prediction.
|
||||
end_datetime (Optional[str]: Ending datetime (exclusive).
|
||||
|
||||
Defaults to end datetime of latest prediction.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `keys` (query, required): Prediction keys.
|
||||
|
||||
- `start_datetime` (query, optional): Starting datetime (inclusive).
|
||||
|
||||
- `end_datetime` (query, optional): Ending datetime (exclusive).
|
||||
|
||||
- `interval` (query, optional): Time duration for each interval. Defaults to 1 hour.
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## PUT /v1/prediction/import/{provider_id}
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_import_provider_v1_prediction_import__provider_id__put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_import_provider_v1_prediction_import__provider_id__put)
|
||||
|
||||
Fastapi Prediction Import Provider
|
||||
|
||||
```
|
||||
Import prediction for given provider ID.
|
||||
|
||||
Args:
|
||||
provider_id: ID of provider to update.
|
||||
data: Prediction data.
|
||||
force_enable: Update data even if provider is disabled.
|
||||
Defaults to False.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `provider_id` (path, required): Provider ID.
|
||||
|
||||
- `force_enable` (query, optional): No description provided.
|
||||
|
||||
**Request Body**:
|
||||
|
||||
- `application/json`: {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PydanticDateTimeDataFrame"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/PydanticDateTimeData"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Data"
|
||||
}
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/prediction/keys
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_keys_get_v1_prediction_keys_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_keys_get_v1_prediction_keys_get)
|
||||
@@ -859,32 +864,7 @@ Args:
|
||||
|
||||
- `end_datetime` (query, optional): Ending datetime (exclusive).
|
||||
|
||||
- `interval` (query, optional): Time duration for each interval. Defaults to 1 hour.
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/prediction/providers
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_providers_get_v1_prediction_providers_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_providers_get_v1_prediction_providers_get)
|
||||
|
||||
Fastapi Prediction Providers Get
|
||||
|
||||
```
|
||||
Get a list of available prediction providers.
|
||||
|
||||
Args:
|
||||
enabled (bool): Return enabled/disabled providers. If unset, return all providers.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `enabled` (query, optional): No description provided.
|
||||
- `interval` (query, optional): Time duration for each interval.
|
||||
|
||||
**Responses**:
|
||||
|
||||
|
3
docs/_static/eos.css
vendored
@@ -1,3 +0,0 @@
|
||||
.wy-nav-content {
|
||||
max-width: 90% !important;
|
||||
}
|
@@ -28,7 +28,7 @@ management.
|
||||
Energy management is the overall process to provide planning data for scheduling the different
|
||||
devices in your system in an optimal way. Energy management cares for the update of predictions and
|
||||
the optimization of the planning based on the simulated behavior of the devices. The planning is on
|
||||
the hour.
|
||||
the hour. Sub-hour energy management is left
|
||||
|
||||
### Optimization
|
||||
|
||||
|
@@ -7,9 +7,10 @@ management.
|
||||
|
||||
## Storing Configuration
|
||||
|
||||
EOS stores configuration data in a `nested structure`. Note that configuration changes inside EOS
|
||||
are updated in memory, meaning all changes will be lost upon restarting the EOS REST server if not
|
||||
saved to the `EOS configuration file`.
|
||||
EOS stores configuration data in a **key-value store**, where a `configuration key` refers to the
|
||||
unique identifier used to store and retrieve specific configuration data. Note that the key-value
|
||||
store is memory-based, meaning all stored data will be lost upon restarting the EOS REST server if
|
||||
not saved to the `EOS configuration file`.
|
||||
|
||||
Some `configuration keys` are read-only and cannot be altered. These keys are either set up by other
|
||||
means, such as environment variables, or determined from other information.
|
||||
@@ -24,8 +25,7 @@ Use endpoint `PUT /v1/config/file` to save the current configuration to the
|
||||
|
||||
### Load Configuration File
|
||||
|
||||
Use endpoint `POST /v1/config/reset` to reset the configuration to the values in the
|
||||
`EOS configuration file`.
|
||||
Use endpoint `POST /v1/config/update` to update the configuration from the `EOS configuration file`.
|
||||
|
||||
## Configuration Sources and Priorities
|
||||
|
||||
@@ -36,25 +36,26 @@ The configuration sources and their priorities are as follows:
|
||||
3. `EOS Configuration File`: Read at startup of the REST server and on request
|
||||
4. `Default Values`
|
||||
|
||||
### Runtime Config Updates
|
||||
### Settings
|
||||
|
||||
The EOS configuration can be updated at runtime. Note that those updates are not persistent
|
||||
automatically. However it is possible to save the configuration to the `EOS configuration file`.
|
||||
Settings are sets of configuration data that take precedence over all other configuration data from
|
||||
different sources. Note that settings are not persistent. To make the current configuration with the
|
||||
current settings persistent, save the configuration to the `EOS configuration file`.
|
||||
|
||||
Use the following endpoints to change the current runtime configuration:
|
||||
Use the following endpoints to change the current configuration settings:
|
||||
|
||||
- `PUT /v1/config`: Update the entire or parts of the configuration.
|
||||
- `PUT /v1/config`: Replaces the entire configuration settings.
|
||||
- `PUT /v1/config/value`: Sets a specific configuration option.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All `configuration keys` can be set by environment variables prefixed with `EOS_` and separated by
|
||||
`__` for nested structures. Environment variables are case insensitive.
|
||||
|
||||
EOS recognizes the following special environment variables (case sensitive):
|
||||
All `configuration keys` can be set by environment variables with the same name. EOS recognizes the
|
||||
following special environment variables:
|
||||
|
||||
- `EOS_CONFIG_DIR`: The directory to search for an EOS configuration file.
|
||||
- `EOS_DIR`: The directory used by EOS for data, which will also be searched for an EOS
|
||||
configuration file.
|
||||
- `EOS_LOGGING_LEVEL`: The logging level to use in EOS.
|
||||
|
||||
### EOS Configuration File
|
||||
|
||||
@@ -65,7 +66,7 @@ If you do not have a configuration file, it will be automatically created on the
|
||||
the REST server in a system-dependent location.
|
||||
|
||||
To determine the location of the configuration file used by EOS, ask the REST server. The endpoint
|
||||
`GET /v1/config` provides the `general.config_file_path` configuration key.
|
||||
`GET /v1/config` provides the `config_file_path` configuration key.
|
||||
|
||||
EOS searches for the configuration file in the following order:
|
||||
|
||||
@@ -74,15 +75,9 @@ EOS searches for the configuration file in the following order:
|
||||
3. A platform-specific default directory for EOS
|
||||
4. The current working directory
|
||||
|
||||
The first configuration file available in these directories is loaded. If no configuration file is
|
||||
found, a default configuration file is created, and the default settings are written to it. The
|
||||
location of the created configuration file follows the same order in which EOS searches for
|
||||
configuration files, and it depends on whether the relevant environment variables are set.
|
||||
|
||||
Use the following endpoints to interact with the configuration file:
|
||||
|
||||
- `PUT /v1/config/file`: Save the current configuration to the configuration file.
|
||||
- `PUT /v1/config/reset`: Reload the configuration file, all unsaved runtime configuration is reset.
|
||||
The first available configuration file found in these directories is loaded. If no configuration
|
||||
file is found, a default configuration file is created in the platform-specific default directory,
|
||||
and default settings are loaded into it.
|
||||
|
||||
### Default Values
|
||||
|
||||
|
@@ -20,8 +20,8 @@ Andreas Schmitz uses [Node-RED](https://nodered.org/) as part of his home automa
|
||||
|
||||
### Node-Red Resources
|
||||
|
||||
- [Installation Guide (German)](https://www.youtube.com/playlist?list=PL8_vk9A-s7zLD865Oou6y3EeQLlNtu-Hn)
|
||||
\— A detailed guide on integrating EOS with `Node-RED`.
|
||||
- [Installation Guide (German)](https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/)
|
||||
\— A detailed guide on integrating an early version of EOS with `Node-RED`.
|
||||
|
||||
## Home Assistant
|
||||
|
||||
@@ -34,8 +34,3 @@ emphasizes local control and user privacy.
|
||||
|
||||
- Duetting's [EOS Home Assistant Addon](https://github.com/Duetting/ha_eos_addon) — Additional
|
||||
details can be found in this [discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).
|
||||
|
||||
## EOS Connect
|
||||
|
||||
[EOS connect](https://github.com/ohAnd/EOS_connect) uses `EOS` for energy management and optimization,
|
||||
and connects to smart home platforms to monitor, forecast, and control energy flows.
|
||||
|
@@ -139,7 +139,7 @@ of components.
|
||||
|
||||

|
||||
|
||||
However, the components are not integrated by the EOS itself, but must be integrated by
|
||||
However, the components are not integrated by the EOS itself, but must be intergrated by
|
||||
the user using an integration solution and currently requires some effort and technical
|
||||
know-how.
|
||||
|
||||
@@ -153,7 +153,7 @@ Node-RED offers a large number of types of nodes that allow access via the proto
|
||||
commonly used in this area, such as Modbus or MQTT. Access to any existing databases,
|
||||
such as InfluxDB or PostgreSQL, is also possible via nodes provided by Node-RED.
|
||||
|
||||
It becomes easier if a smart home solution like Home Assistant, openHAB or ioBroker or
|
||||
It becomes easier if a smart home solution like Homa Assistant, openHAB or ioBroker or
|
||||
solutions such as evcc or openWB are already in use. In this case, these smart home
|
||||
solutions already take over the technical integration and communication with the components
|
||||
at a technical level and Node-RED offers nodes for accessing these solutions, so that the
|
||||
@@ -161,7 +161,7 @@ corresponding sources can be easily integrated into a flow.
|
||||
|
||||
In Home Assistant you could use an automation to prepare the input payload for EOS and
|
||||
then use the RESTful integration to call EOS. Based on this concept there is already a
|
||||
Home Assistant add-on created by [Duetting](#duetting-solution).
|
||||
home assistand add-on created by [Duetting](#duetting-solution).
|
||||
|
||||
The plan created by EOS must also be executed via the chosen integration solution,
|
||||
with the respective devices receiving their instructions according to the plan.
|
||||
@@ -174,7 +174,7 @@ but usually find good local optima very quickly in a large solution space.
|
||||
|
||||
## Links
|
||||
|
||||
- [German Videos explaining the basic concept and installation process of EOS (YouTube)](https://www.youtube.com/playlist?list=PL8_vk9A-s7zLD865Oou6y3EeQLlNtu-Hn)
|
||||
- [German Video explaining the basic concept and installation process for the early version of EOS (YouTube)](https://www.youtube.com/live/ftQULW4-1ts?si=oDdBBifCpUmiCXaY)
|
||||
- [German Forum of Akkudoktor EOS](https://akkudoktor.net/c/der-akkudoktor/eos)
|
||||
- [Akkudoktor-EOS GitHub Repository](https://github.com/Akkudoktor-EOS/EOS)
|
||||
- [Latest EOS Documentation](https://akkudoktor-eos.readthedocs.io/en/latest/)
|
||||
|
@@ -1,75 +0,0 @@
|
||||
% SPDX-License-Identifier: Apache-2.0
|
||||
(logging-page)=
|
||||
|
||||
# Logging
|
||||
|
||||
EOS automatically records important events and messages to help you understand what’s happening and
|
||||
to troubleshoot problems.
|
||||
|
||||
## How Logging Works
|
||||
|
||||
- By default, logs are shown in your terminal (console).
|
||||
- You can also save logs to a file for later review.
|
||||
- Log files are rotated automatically to avoid becoming too large.
|
||||
|
||||
## Controlling Log Details
|
||||
|
||||
### 1. Command-Line Option
|
||||
|
||||
Set the amount of log detail shown on the console by using `--log-level` when starting EOS.
|
||||
|
||||
Example:
|
||||
|
||||
```{eval-rst}
|
||||
.. tabs::
|
||||
|
||||
.. tab:: Windows
|
||||
|
||||
.. code-block:: powershell
|
||||
|
||||
.venv\Scripts\python src/akkudoktoreos/server/eos.py --log-level DEBUG
|
||||
|
||||
.. tab:: Linux
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
.venv/bin/python src/akkudoktoreos/server/eos.py --log-level DEBUG
|
||||
|
||||
```
|
||||
|
||||
Common levels:
|
||||
|
||||
- DEBUG (most detail)
|
||||
- INFO (default)
|
||||
- WARNING
|
||||
- ERROR
|
||||
- CRITICAL (least detail)
|
||||
|
||||
### 2. Configuration File
|
||||
|
||||
You can also set logging options in your EOS configuration file (EOS.config.json).
|
||||
|
||||
```Json
|
||||
{
|
||||
"logging": {
|
||||
"console_level": "INFO",
|
||||
"file_level": "DEBUG"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Environment Variable
|
||||
|
||||
You can also control the log level by setting the `EOS_LOGGING__CONSOLE_LEVEL` and the
|
||||
`EOS_LOGGING__FILE_LEVEL` environment variables.
|
||||
|
||||
```bash
|
||||
EOS_LOGGING__CONSOLE_LEVEL="INFO"
|
||||
EOS_LOGGING__FILE_LEVEL="DEBUG"
|
||||
```
|
||||
|
||||
## File Logging
|
||||
|
||||
If the `file_level` configuration is set, log records are written to a rotating log file. The log
|
||||
file is in the data output directory and named `eos.log`. You may directly read the file or use
|
||||
the `/v1/logging/log` endpoint to access the file log.
|
@@ -56,21 +56,21 @@ A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html)
|
||||
The EOS measurement store provides for storing meter readings of loads. There are currently five loads
|
||||
foreseen. The associated `measurement key`s are:
|
||||
|
||||
- `load0_mr`: Load0 meter reading [kWh]
|
||||
- `load1_mr`: Load1 meter reading [kWh]
|
||||
- `load2_mr`: Load2 meter reading [kWh]
|
||||
- `load3_mr`: Load3 meter reading [kWh]
|
||||
- `load4_mr`: Load4 meter reading [kWh]
|
||||
- `measurement_load0_mr`: Load0 meter reading [kWh]
|
||||
- `measurement_load1_mr`: Load1 meter reading [kWh]
|
||||
- `measurement_load2_mr`: Load2 meter reading [kWh]
|
||||
- `measurement_load3_mr`: Load3 meter reading [kWh]
|
||||
- `measurement_load4_mr`: Load4 meter reading [kWh]
|
||||
|
||||
For ease of use, you can assign descriptive names to the `measurement key`s to represent your
|
||||
system's load sources. Use the following `configuration options` to set these names
|
||||
(e.g., 'Dish Washer', 'Heat Pump'):
|
||||
|
||||
- `load0_name`: Name of the load0 source
|
||||
- `load1_name`: Name of the load1 source
|
||||
- `load2_name`: Name of the load2 source
|
||||
- `load3_name`: Name of the load3 source
|
||||
- `load4_name`: Name of the load4 source
|
||||
- `measurement_load0_name`: Name of the load0 source
|
||||
- `measurement_load1_name`: Name of the load1 source
|
||||
- `measurement_load2_name`: Name of the load2 source
|
||||
- `measurement_load3_name`: Name of the load3 source
|
||||
- `measurement_load4_name`: Name of the load4 source
|
||||
|
||||
Load measurements can be stored for any datetime. The values between different meter readings are
|
||||
linearly approximated. Since optimization occurs on the hour, storing values between hours is
|
||||
@@ -84,8 +84,8 @@ for specified intervals, usually one hour. This aggregated data can be used for
|
||||
The EOS measurement store also allows for the storage of meter readings for grid import and export.
|
||||
The associated `measurement key`s are:
|
||||
|
||||
- `grid_export_mr`: Export to grid meter reading [kWh]
|
||||
- `grid_import_mr`: Import from grid meter reading [kWh]
|
||||
- `measurement_grid_export_mr`: Export to grid meter reading [kWh]
|
||||
- `measurement_grid_import_mr`: Import from grid meter reading [kWh]
|
||||
|
||||
:::{admonition} Todo
|
||||
:class: note
|
||||
|
@@ -21,7 +21,6 @@ including electricity prices, battery storage capacity, PV forecast, and tempera
|
||||
"strompreis_euro_pro_wh": [0.0003784, 0.0003868, ..., 0.00034102, 0.00033709]
|
||||
},
|
||||
"pv_akku": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 12000,
|
||||
"charging_efficiency": 0.92,
|
||||
"discharging_efficiency": 0.92,
|
||||
@@ -31,12 +30,9 @@ including electricity prices, battery storage capacity, PV forecast, and tempera
|
||||
"max_soc_percentage": 100
|
||||
},
|
||||
"inverter": {
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 15500
|
||||
"battery_id": "battery1",
|
||||
},
|
||||
"eauto": {
|
||||
"device_id": "auto1",
|
||||
"capacity_wh": 64000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -99,7 +95,6 @@ Verify prices against your local tariffs.
|
||||
|
||||
#### Configuration
|
||||
|
||||
- `device_id`: ID of battery
|
||||
- `capacity_wh`: Total battery capacity in Wh
|
||||
- `charging_efficiency`: Charging efficiency (0-1)
|
||||
- `discharging_efficiency`: Discharging efficiency (0-1)
|
||||
@@ -113,13 +108,10 @@ Verify prices against your local tariffs.
|
||||
|
||||
### Inverter
|
||||
|
||||
- `device_id`: ID of inverter
|
||||
- `max_power_wh`: Maximum inverter power in Wh
|
||||
- `battery_id`: ID of battery
|
||||
|
||||
### Electric Vehicle (EV)
|
||||
|
||||
- `device_id`: ID of electric vehicle
|
||||
- `capacity_wh`: Battery capacity in Wh
|
||||
- `charging_efficiency`: Charging efficiency (0-1)
|
||||
- `discharging_efficiency`: Discharging efficiency (0-1)
|
||||
|
@@ -20,14 +20,10 @@ data is lost on re-start of the EOS REST server.
|
||||
## Prediction Providers
|
||||
|
||||
Most predictions can be sourced from various providers. The specific provider to use is configured
|
||||
in the EOS configuration and can be set by prediction type. For example:
|
||||
in the EOS configuration. For example:
|
||||
|
||||
```python
|
||||
{
|
||||
"weather": {
|
||||
"provider": "ClearOutside"
|
||||
}
|
||||
}
|
||||
weather_provider = "ClearOutside"
|
||||
```
|
||||
|
||||
Some providers offer multiple prediction keys. For instance, a weather provider might provide data
|
||||
@@ -78,7 +74,7 @@ predictions are adjusted by real data from your system's measurements if given t
|
||||
|
||||
For example, the load prediction provider `LoadAkkudoktor` takes generic load data assembled by
|
||||
Akkudoktor.net, maps that to the yearly energy consumption given in the configuration option
|
||||
`loadakkudoktor_year_energy`, and finally adjusts the predicted load by the `loads`
|
||||
`loadakkudoktor_year_energy`, and finally adjusts the predicted load by the `measurement_loads`
|
||||
of your system.
|
||||
|
||||
## Prediction Updates
|
||||
@@ -114,45 +110,23 @@ Prediction keys:
|
||||
|
||||
Configuration options:
|
||||
|
||||
- `elecprice`: Electricity price configuration.
|
||||
|
||||
- `provider`: Electricity price provider id of provider to be used.
|
||||
- `elecprice_provider`: Electricity price provider id of provider to be used.
|
||||
|
||||
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `ElecPriceEnergyCharts`: Retrieves from Energy-Charts.info.
|
||||
- `ElecPriceImport`: Imports from a file or JSON string.
|
||||
|
||||
- `charges_kwh`: Electricity price charges (€/kWh).
|
||||
- `vat_rate`: VAT rate factor applied to electricity price when charges are used (default: 1.19).
|
||||
- `provider_settings.import_file_path`: Path to the file to import electricity price forecast data from.
|
||||
- `provider_settings.import_json`: JSON string, dictionary of electricity price forecast value lists.
|
||||
- `elecprice_charges_kwh`: Electricity price charges (€/kWh).
|
||||
- `elecpriceimport_file_path`: Path to the file to import electricity price forecast data from.
|
||||
- `elecpriceimport_json`: JSON string, dictionary of electricity price forecast value lists.
|
||||
|
||||
### ElecPriceAkkudoktor Provider
|
||||
|
||||
The `ElecPriceAkkudoktor` provider retrieves electricity prices directly from **Akkudoktor.net**,
|
||||
which supplies price data for the next 24 hours. For periods beyond 24 hours, the provider generates
|
||||
prices by extrapolating historical price data combined with the most recent actual prices obtained
|
||||
from Akkudoktor.net. Electricity price charges given in the `charges_kwh` configuration
|
||||
from Akkudoktor.net. Electricity price charges given in the `elecprice_charges_kwh` configuration
|
||||
option are added.
|
||||
|
||||
### ElecPriceEnergyCharts Provider
|
||||
|
||||
The `ElecPriceEnergyCharts` provider retrieves day-ahead electricity market prices from
|
||||
[Energy-Charts.info](https://www.Energy-Charts.info). It supports both short-term and extended forecasting by combining
|
||||
real-time market data with historical price trends.
|
||||
|
||||
- For the next 24 hours, market prices are fetched directly from Energy-Charts.info.
|
||||
- For periods beyond 24 hours, prices are estimated using extrapolation based on historical data and the latest
|
||||
available market values.
|
||||
|
||||
Charges and VAT
|
||||
|
||||
- If `charges_kwh` configuration option is greater than 0, the electricity price is calculated as:
|
||||
`(market price + charges_kwh) * vat_rate` where `vat_rate` is configurable (default: 1.19 for 19% VAT).
|
||||
- If `charges_kwh` is set to 0, the electricity price is simply: `market_price` (no VAT applied).
|
||||
|
||||
**Note:** For the most accurate forecasts, it is recommended to set the `historic_hours` parameter to 840.
|
||||
|
||||
### ElecPriceImport Provider
|
||||
|
||||
The `ElecPriceImport` provider is designed to import electricity prices from a file or a JSON
|
||||
@@ -164,11 +138,8 @@ The prediction key for the electricity price forecast data is:
|
||||
- `elecprice_marketprice_wh`: Electricity market price per Wh (€/Wh).
|
||||
|
||||
The electricity proce forecast data must be provided in one of the formats described in
|
||||
<project:#prediction-import-providers>. The data source can be given in the
|
||||
`import_file_path` or `import_json` configuration option.
|
||||
|
||||
The data may additionally or solely be provided by the
|
||||
**PUT** `/v1/prediction/import/ElecPriceImport` endpoint.
|
||||
<project:#prediction-import-providers>. The data source must be given in the
|
||||
`elecpriceimport_file_path` or `elecpriceimport_json` configuration option.
|
||||
|
||||
## Load Prediction
|
||||
|
||||
@@ -180,17 +151,14 @@ Prediction keys:
|
||||
|
||||
Configuration options:
|
||||
|
||||
- `load`: Load configuration.
|
||||
|
||||
- `provider`: Load provider id of provider to be used.
|
||||
- `load_provider`: Load provider id of provider to be used.
|
||||
|
||||
- `LoadAkkudoktor`: Retrieves from local database.
|
||||
- `LoadVrm`: Retrieves data from the VRM API by Victron Energy.
|
||||
- `LoadImport`: Imports from a file or JSON string.
|
||||
|
||||
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
|
||||
- `provider_settings.loadimport_file_path`: Path to the file to import load forecast data from.
|
||||
- `provider_settings.loadimport_json`: JSON string, dictionary of load forecast value lists.
|
||||
- `loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
|
||||
- `loadimport_file_path`: Path to the file to import load forecast data from.
|
||||
- `loadimport_json`: JSON string, dictionary of load forecast value lists.
|
||||
|
||||
### LoadAkkudoktor Provider
|
||||
|
||||
@@ -198,27 +166,6 @@ The `LoadAkkudoktor` provider retrieves generic load data from a local database
|
||||
align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration
|
||||
option.
|
||||
|
||||
### LoadVrm Provider
|
||||
|
||||
The `LoadVrm` provider retrieves load forecast data from the VRM API by Victron Energy.
|
||||
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
|
||||
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
|
||||
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
|
||||
|
||||
```python
|
||||
{
|
||||
"load": {
|
||||
"provider": "LoadVrm",
|
||||
"provider_settings": {
|
||||
"load_vrm_token": "dummy-token",
|
||||
"load_vrm_idsite": 12345
|
||||
}
|
||||
```
|
||||
|
||||
The prediction keys for the load forecast data are:
|
||||
|
||||
- `load_mean`: Predicted load mean value (W).
|
||||
|
||||
### LoadImport Provider
|
||||
|
||||
The `LoadImport` provider is designed to import load forecast data from a file or a JSON
|
||||
@@ -232,12 +179,9 @@ The prediction keys for the load forecast data are:
|
||||
- `load_mean_adjusted`: Predicted load mean value adjusted by load measurement (W).
|
||||
|
||||
The load forecast data must be provided in one of the formats described in
|
||||
<project:#prediction-import-providers>. The data source can be given in the `loadimport_file_path`
|
||||
<project:#prediction-import-providers>. The data source must be given in the `loadimport_file_path`
|
||||
or `loadimport_json` configuration option.
|
||||
|
||||
The data may additionally or solely be provided by the
|
||||
**PUT** `/v1/prediction/import/LoadImport` endpoint.
|
||||
|
||||
## PV Power Prediction
|
||||
|
||||
Prediction keys:
|
||||
@@ -247,52 +191,48 @@ Prediction keys:
|
||||
|
||||
Configuration options:
|
||||
|
||||
- `general`: General configuration.
|
||||
|
||||
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
|
||||
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
|
||||
|
||||
- `pvforecast`: PV forecast configuration.
|
||||
|
||||
- `provider`: PVForecast provider id of provider to be used.
|
||||
- `pvforecast_provider`: PVForecast provider id of provider to be used.
|
||||
|
||||
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `PVForecastVrm`: Retrieves data from the VRM API by Victron Energy.
|
||||
- `PVForecastImport`: Imports from a file or JSON string.
|
||||
|
||||
- `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
- `planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
|
||||
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
|
||||
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
|
||||
- `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
- `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
|
||||
Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
- `planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
- `planes[].peakpower`: Nominal power of PV system in kW.
|
||||
- `planes[].pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
- `planes[].mountingplace`: Type of mounting for PV system.
|
||||
Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
- `planes[].loss`: Sum of PV system losses in percent
|
||||
- `planes[].trackingtype`: Type of suntracking.
|
||||
0=fixed,
|
||||
- `pvforecast<0..5>_userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW.
|
||||
- `pvforecast<0..5>_pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
- `pvforecast<0..5>_mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing
|
||||
and 'building' for building-integrated.
|
||||
- `pvforecast<0..5>_loss`: Sum of PV system losses in percent
|
||||
- `pvforecast<0..5>_trackingtype`: Type of suntracking. 0=fixed,
|
||||
1=single horizontal axis aligned north-south,
|
||||
2=two-axis tracking,
|
||||
3=vertical axis tracking,
|
||||
4=single horizontal axis aligned east-west,
|
||||
5=single inclined axis aligned north-south.
|
||||
- `planes[].optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
- `planes[].optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
- `planes[].albedo`: Proportion of the light hitting the ground that it reflects back.
|
||||
- `planes[].module_model`: Model of the PV modules of this plane.
|
||||
- `planes[].inverter_model`: Model of the inverter of this plane.
|
||||
- `planes[].inverter_paco`: AC power rating of the inverter. [W]
|
||||
- `planes[].modules_per_string`: Number of the PV modules of the strings of this plane.
|
||||
- `planes[].strings_per_inverter`: Number of the strings of the inverter of this plane.
|
||||
- `provider_settings.import_file_path`: Path to the file to import PV forecast data from.
|
||||
- `provider_settings.import_json`: JSON string, dictionary of PV forecast value lists.
|
||||
- `pvforecast<0..5>_optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
- `pvforecast<0..5>_optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
- `pvforecast<0..5>_albedo`: Proportion of the light hitting the ground that it reflects back.
|
||||
- `pvforecast<0..5>_module_model`: Model of the PV modules of this plane.
|
||||
- `pvforecast<0..5>_inverter_model`: Model of the inverter of this plane.
|
||||
- `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W]
|
||||
- `pvforecast<0..5>_modules_per_string`: Number of the PV modules of the strings of this plane.
|
||||
- `pvforecast<0..5>_strings_per_inverter`: Number of the strings of the inverter of this plane.
|
||||
- `pvforecastimport_file_path`: Path to the file to import PV forecast data from.
|
||||
- `pvforecastimport_json`: JSON string, dictionary of PV forecast value lists.
|
||||
|
||||
---
|
||||
|
||||
Detailed definitions taken from
|
||||
[PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en).
|
||||
Some of the configuration options directly follow the
|
||||
[PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en)
|
||||
nomenclature.
|
||||
|
||||
- `pvtechchoice`
|
||||
Detailed definitions taken from **PVGIS**:
|
||||
|
||||
- `pvforecast<0..5>_pvtechchoice`
|
||||
|
||||
The performance of PV modules depends on the temperature and on the solar irradiance, but the exact
|
||||
dependence varies between different types of PV modules. At the moment we can estimate the losses
|
||||
@@ -311,7 +251,7 @@ variations of the spectrum of sunlight affects the overall energy production fro
|
||||
the moment this calculation can be done for crystalline silicon and CdTe modules. Note that this
|
||||
calculation is not yet available when using the NSRDB solar radiation database.
|
||||
|
||||
- `peakpower`
|
||||
- `pvforecast<0..5>_peakpower`
|
||||
|
||||
This is the power that the manufacturer declares that the PV array can produce under standard test
|
||||
conditions (STC), which are a constant 1000W of solar irradiation per square meter in the plane of
|
||||
@@ -327,7 +267,7 @@ value and the bifaciality factor, φ (if reported in the module data sheet) as:
|
||||
P_BNPI = P_STC \* (1 + φ \* 0.135). NB this bifacial approach is not appropriate for BAPV or BIPV
|
||||
installations or for modules mounting on a N-S axis i.e. facing E-W.
|
||||
|
||||
- `loss`
|
||||
- `pvforecast<0..5>_loss`
|
||||
|
||||
The estimated system losses are all the losses in the system, which cause the power actually
|
||||
delivered to the electricity grid to be lower than the power produced by the PV modules. There are
|
||||
@@ -339,7 +279,7 @@ in the first years.
|
||||
We have given a default value of 14% for the overall losses. If you have a good idea that your value
|
||||
will be different (maybe due to a really high-efficiency inverter) you may reduce this value a little.
|
||||
|
||||
- `mountingplace`
|
||||
- `pvforecast<0..5>_mountingplace`
|
||||
|
||||
For fixed (non-tracking) systems, the way the modules are mounted will have an influence on the
|
||||
temperature of the module, which in turn affects the efficiency. Experiments have shown that if the
|
||||
@@ -355,7 +295,7 @@ Some types of mounting are in between these two extremes, for instance if the mo
|
||||
a roof with curved roof tiles, allowing air to move behind the modules. In such cases, the
|
||||
performance will be somewhere between the results of the two calculations that are possible here.
|
||||
|
||||
- `userhorizon`
|
||||
- `pvforecast<0..5>_userhorizon`
|
||||
|
||||
Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. In the user horizon
|
||||
data each number represents the horizon height in degrees in a certain compass direction around the
|
||||
@@ -372,11 +312,11 @@ Most of the configuration options are in line with the
|
||||
|
||||
Detailed definitions from **PVLib** for PVGIS data.
|
||||
|
||||
- `surface_tilt`:
|
||||
- `pvforecast<0..5>_surface_tilt`:
|
||||
|
||||
Tilt angle from horizontal plane.
|
||||
|
||||
- `surface_azimuth`
|
||||
- `pvforecast<0..5>_surface_azimuth`
|
||||
|
||||
Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180,
|
||||
west=270). This is offset 180 degrees from the convention used by PVGIS.
|
||||
@@ -388,86 +328,51 @@ west=270). This is offset 180 degrees from the convention used by PVGIS.
|
||||
The `PVForecastAkkudoktor` provider retrieves the PV power forecast data directly from
|
||||
**Akkudoktor.net**.
|
||||
|
||||
The following prediction configuration options of the PV system must be set:
|
||||
The following general configuration options of the PV system must be set:
|
||||
|
||||
- `general.latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
|
||||
- `general.longitude`: Longitude in decimal degrees, within -180 to 180 (°)
|
||||
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
|
||||
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
|
||||
|
||||
For each plane of the PV system the following configuration options must be set:
|
||||
For each plane `<0..5>` of the PV system the following configuration options must be set:
|
||||
|
||||
- `pvforecast.planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
- `pvforecast.planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
|
||||
- `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
- `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
|
||||
Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
- `pvforecast.planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
- `pvforecast.planes[].inverter_paco`: AC power rating of the inverter. [W]
|
||||
- `pvforecast.planes[].peakpower`: Nominal power of PV system in kW.
|
||||
- `pvforecast<0..5>_userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
- `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W]
|
||||
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW.
|
||||
|
||||
Example:
|
||||
|
||||
```Python
|
||||
{
|
||||
"general": {
|
||||
"latitude": 50.1234,
|
||||
"longitude": 9.7654,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400
|
||||
}
|
||||
]
|
||||
}
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
"pvforecast4_peakpower": None,
|
||||
}
|
||||
```
|
||||
|
||||
### PVForecastVrm Provider
|
||||
|
||||
The `PVForecastVrm` provider retrieves pv power forecast data from the VRM API by Victron Energy.
|
||||
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
|
||||
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
|
||||
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
|
||||
|
||||
```python
|
||||
{
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastVrm",
|
||||
"provider_settings": {
|
||||
"pvforecast_vrm_token": "dummy-token",
|
||||
"pvforecast_vrm_idsite": 12345
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The prediction keys for the PV forecast data are:
|
||||
|
||||
- `pvforecast_dc_power`: Total DC power (W).
|
||||
|
||||
### PVForecastImport Provider
|
||||
|
||||
The `PVForecastImport` provider is designed to import PV forecast data from a file or a JSON
|
||||
@@ -476,15 +381,12 @@ becomes available.
|
||||
|
||||
The prediction keys for the PV forecast data are:
|
||||
|
||||
- `pvforecast_ac_power`: Total AC power (W).
|
||||
- `pvforecast_dc_power`: Total DC power (W).
|
||||
- `pvforecast_ac_power`: Total DC power (W).
|
||||
- `pvforecast_dc_power`: Total AC power (W).
|
||||
|
||||
The PV forecast data must be provided in one of the formats described in
|
||||
<project:#prediction-import-providers>. The data source can be given in the
|
||||
`import_file_path` or `import_json` configuration option.
|
||||
|
||||
The data may additionally or solely be provided by the
|
||||
**PUT** `/v1/prediction/import/PVForecastImport` endpoint.
|
||||
<project:#prediction-import-providers>. The data source must be given in the
|
||||
`pvforecastimport_file_path` or `pvforecastimport_json` configuration option.
|
||||
|
||||
## Weather Prediction
|
||||
|
||||
@@ -515,16 +417,14 @@ Prediction keys:
|
||||
|
||||
Configuration options:
|
||||
|
||||
- `weather`: General weather configuration.
|
||||
|
||||
- `provider`: Load provider id of provider to be used.
|
||||
- `weather_provider`: Load provider id of provider to be used.
|
||||
|
||||
- `BrightSky`: Retrieves from [BrightSky](https://api.brightsky.dev).
|
||||
- `ClearOutside`: Retrieves from [ClearOutside](https://clearoutside.com/forecast).
|
||||
- `LoadImport`: Imports from a file or JSON string.
|
||||
|
||||
- `provider_settings.import_file_path`: Path to the file to import weatherforecast data from.
|
||||
- `provider_settings.import_json`: JSON string, dictionary of weather forecast value lists.
|
||||
- `weatherimport_file_path`: Path to the file to import weatherforecast data from.
|
||||
- `weatherimport_json`: JSON string, dictionary of weather forecast value lists.
|
||||
|
||||
### BrightSky Provider
|
||||
|
||||
@@ -581,7 +481,7 @@ The `WeatherImport` provider is designed to import weather forecast data from a
|
||||
string. An external entity should update the file or JSON string whenever new prediction data
|
||||
becomes available.
|
||||
|
||||
The prediction keys for the weather forecast data are:
|
||||
The prediction keys for the PV forecast data are:
|
||||
|
||||
- `weather_dew_point`: Dew Point (°C)
|
||||
- `weather_dhi`: Diffuse Horizontal Irradiance (W/m2)
|
||||
@@ -607,8 +507,5 @@ The prediction keys for the weather forecast data are:
|
||||
- `weather_wind_speed`: Wind Speed (kmph)
|
||||
|
||||
The PV forecast data must be provided in one of the formats described in
|
||||
<project:#prediction-import-providers>. The data source can be given in the
|
||||
`import_file_path` or `import_json` configuration option.
|
||||
|
||||
The data may additionally or solely be provided by the
|
||||
**PUT** `/v1/prediction/import/WeatherImport` endpoint.
|
||||
<project:#prediction-import-providers>. The data source must be given in the
|
||||
`weatherimport_file_path` or `pvforecastimport_json` configuration option.
|
||||
|
@@ -99,7 +99,6 @@ html_theme_options = {
|
||||
"logo_only": False,
|
||||
"titles_only": True,
|
||||
}
|
||||
html_css_files = ["eos.css"]
|
||||
|
||||
# -- Options for autodoc -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html
|
||||
|
@@ -40,7 +40,6 @@ akkudoktoreos/optimization.md
|
||||
akkudoktoreos/prediction.md
|
||||
akkudoktoreos/measurement.md
|
||||
akkudoktoreos/integration.md
|
||||
akkudoktoreos/logging.md
|
||||
akkudoktoreos/serverapi.md
|
||||
akkudoktoreos/api.rst
|
||||
|
||||
|
9638
openapi.json
@@ -43,18 +43,12 @@ profile = "black"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
exclude = [
|
||||
"tests",
|
||||
"scripts",
|
||||
]
|
||||
output-format = "full"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"F", # Enable all `Pyflakes` rules.
|
||||
"D", # Enable all `pydocstyle` rules, limiting to those that adhere to the
|
||||
# Google convention via `convention = "google"`, below.
|
||||
"S", # Enable all `flake8-bandit` rules.
|
||||
]
|
||||
ignore = [
|
||||
# Prevent errors due to ruff false positives
|
||||
|
@@ -1,15 +1,14 @@
|
||||
-r requirements.txt
|
||||
gitlint==0.19.1
|
||||
GitPython==3.1.45
|
||||
gitpython==3.1.44
|
||||
linkify-it-py==2.0.3
|
||||
myst-parser==4.0.1
|
||||
sphinx==8.2.3
|
||||
sphinx==8.1.3
|
||||
sphinx_rtd_theme==3.0.2
|
||||
sphinx-tabs==3.4.7
|
||||
pymarkdownlnt==0.9.32
|
||||
pytest==8.4.2
|
||||
pytest-cov==7.0.0
|
||||
pytest==8.3.5
|
||||
pytest-cov==6.0.0
|
||||
pytest-xprocess==1.0.2
|
||||
pre-commit
|
||||
mypy==1.18.2
|
||||
types-requests==2.32.4.20250913
|
||||
pandas-stubs==2.3.2.250827
|
||||
mypy==1.15.0
|
||||
types-requests==2.32.0.20250306
|
||||
pandas-stubs==2.2.3.250308
|
||||
|
@@ -1,25 +1,16 @@
|
||||
cachebox==5.0.2
|
||||
numpy==2.3.3
|
||||
numpydantic==1.6.11
|
||||
matplotlib==3.10.6
|
||||
fastapi[standard]==0.115.14
|
||||
python-fasthtml==0.12.29
|
||||
MonsterUI==1.0.29
|
||||
markdown-it-py==4.0.0
|
||||
mdit-py-plugins==0.5.0
|
||||
bokeh==3.8.0
|
||||
uvicorn==0.36.0
|
||||
scikit-learn==1.7.2
|
||||
timezonefinder==7.0.2
|
||||
deap==1.4.3
|
||||
requests==2.32.5
|
||||
pandas==2.3.2
|
||||
pendulum==3.1.0
|
||||
platformdirs==4.4.0
|
||||
psutil==7.1.0
|
||||
pvlib==0.13.1
|
||||
pydantic==2.11.9
|
||||
statsmodels==0.14.5
|
||||
pydantic-settings==2.11.0
|
||||
linkify-it-py==2.0.3
|
||||
loguru==0.7.3
|
||||
numpy==2.2.4
|
||||
numpydantic==1.6.8
|
||||
matplotlib==3.10.1
|
||||
fastapi[standard]==0.115.11
|
||||
python-fasthtml==0.12.4
|
||||
uvicorn==0.34.0
|
||||
scikit-learn==1.6.1
|
||||
timezonefinder==6.5.8
|
||||
deap==1.4.2
|
||||
requests==2.32.3
|
||||
pandas==2.2.3
|
||||
pendulum==3.0.0
|
||||
platformdirs==4.3.7
|
||||
pvlib==0.12.0
|
||||
pydantic==2.10.6
|
||||
statsmodels==0.14.4
|
||||
|
@@ -150,7 +150,7 @@ def main():
|
||||
|
||||
try:
|
||||
if args.input_file:
|
||||
with open(args.input_file, "r", encoding="utf-8", newline=None) as f:
|
||||
with open(args.input_file, "r", encoding="utf8") as f:
|
||||
content = f.read()
|
||||
elif args.input:
|
||||
content = args.input
|
||||
@@ -164,7 +164,7 @@ def main():
|
||||
)
|
||||
if args.output_file:
|
||||
# Write to file
|
||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
||||
with open(args.output_file, "w", encoding="utf8") as f:
|
||||
f.write(extracted_content)
|
||||
else:
|
||||
# Write to std output
|
||||
|
@@ -2,294 +2,135 @@
|
||||
"""Utility functions for Configuration specification generation."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
||||
from pydantic_core import PydanticUndefined
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS, GeneralSettings, get_config
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
||||
logger = get_logger(__name__)
|
||||
|
||||
documented_types: set[PydanticBaseModel] = set()
|
||||
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
|
||||
config_eos = get_config()
|
||||
|
||||
global_config_dict: dict[str, Any] = dict()
|
||||
# Fixed set of prefixes to filter configuration values and their respective titles
|
||||
CONFIG_PREFIXES = {
|
||||
"battery": "Battery Device Simulation Configuration",
|
||||
"bev": "Battery Electric Vehicle Device Simulation Configuration",
|
||||
"dishwasher": "Dishwasher Device Simulation Configuration",
|
||||
"inverter": "Inverter Device Simulation Configuration",
|
||||
"measurement": "Measurement Configuration",
|
||||
"optimization": "General Optimization Configuration",
|
||||
"server": "Server Configuration",
|
||||
"elecprice": "Electricity Price Prediction Configuration",
|
||||
"load": "Load Prediction Configuration",
|
||||
"logging": "Logging Configuration",
|
||||
"prediction": "General Prediction Configuration",
|
||||
"pvforecast": "PV Forecast Configuration",
|
||||
"weather": "Weather Forecast Configuration",
|
||||
}
|
||||
|
||||
# Static set of configuration names to include in a separate table
|
||||
GENERAL_CONFIGS = [
|
||||
"config_default_file_path",
|
||||
"config_file_path",
|
||||
"config_folder_path",
|
||||
"config_keys",
|
||||
"config_keys_read_only",
|
||||
"data_cache_path",
|
||||
"data_cache_subpath",
|
||||
"data_folder_path",
|
||||
"data_output_path",
|
||||
"data_output_subpath",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"package_root_path",
|
||||
"timezone",
|
||||
]
|
||||
|
||||
|
||||
def get_title(config: PydanticBaseModel) -> str:
|
||||
if config.__doc__ is None:
|
||||
raise NameError(f"Missing docstring: {config}")
|
||||
return config.__doc__.strip().splitlines()[0].strip(".")
|
||||
|
||||
|
||||
def get_body(config: PydanticBaseModel) -> str:
|
||||
if config.__doc__ is None:
|
||||
raise NameError(f"Missing docstring: {config}")
|
||||
return textwrap.dedent("\n".join(config.__doc__.strip().splitlines()[1:])).strip()
|
||||
|
||||
|
||||
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
|
||||
resolved_types: list[tuple[type, list[str]]] = []
|
||||
|
||||
origin = getattr(field_type, "__origin__", field_type)
|
||||
if origin is Union:
|
||||
for arg in getattr(field_type, "__args__", []):
|
||||
resolved_types.extend(resolve_nested_types(arg, parent_types))
|
||||
elif origin is list:
|
||||
for arg in getattr(field_type, "__args__", []):
|
||||
resolved_types.extend(resolve_nested_types(arg, parent_types + ["list"]))
|
||||
else:
|
||||
resolved_types.append((field_type, parent_types))
|
||||
|
||||
return resolved_types
|
||||
|
||||
|
||||
def create_model_from_examples(
|
||||
model_class: PydanticBaseModel, multiple: bool
|
||||
) -> list[PydanticBaseModel]:
|
||||
"""Create a model instance with default or example values, respecting constraints."""
|
||||
return [
|
||||
model_class(**data) for data in get_model_structure_from_examples(model_class, multiple)
|
||||
]
|
||||
|
||||
|
||||
def build_nested_structure(keys: list[str], value: Any) -> Any:
|
||||
if not keys:
|
||||
return value
|
||||
|
||||
current_key = keys[0]
|
||||
if current_key == "list":
|
||||
return [build_nested_structure(keys[1:], value)]
|
||||
else:
|
||||
return {current_key: build_nested_structure(keys[1:], value)}
|
||||
|
||||
|
||||
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
|
||||
default_value = ""
|
||||
if regular_field:
|
||||
if (val := field_info.default) is not PydanticUndefined:
|
||||
default_value = val
|
||||
else:
|
||||
default_value = "required"
|
||||
else:
|
||||
default_value = "N/A"
|
||||
return default_value
|
||||
|
||||
|
||||
def get_type_name(field_type: type) -> str:
|
||||
type_name = str(field_type).replace("typing.", "").replace("pathlib._local", "pathlib")
|
||||
if type_name.startswith("<class"):
|
||||
type_name = field_type.__name__
|
||||
return type_name
|
||||
|
||||
|
||||
def generate_config_table_md(
|
||||
config: PydanticBaseModel,
|
||||
toplevel_keys: list[str],
|
||||
prefix: str,
|
||||
toplevel: bool = False,
|
||||
extra_config: bool = False,
|
||||
) -> str:
|
||||
def generate_config_table_md(configs, title):
|
||||
"""Generate a markdown table for given configurations.
|
||||
|
||||
Args:
|
||||
config (PydanticBaseModel): PydanticBaseModel configuration definition.
|
||||
prefix (str): Prefix for table entries.
|
||||
configs (dict): Configuration values with keys and their descriptions.
|
||||
title (str): Title for the table.
|
||||
|
||||
Returns:
|
||||
str: The markdown table as a string.
|
||||
"""
|
||||
table = ""
|
||||
if toplevel:
|
||||
title = get_title(config)
|
||||
if not configs:
|
||||
return ""
|
||||
|
||||
heading_level = "###" if extra_config else "##"
|
||||
env_header = ""
|
||||
env_header_underline = ""
|
||||
env_width = ""
|
||||
if not extra_config:
|
||||
env_header = "| Environment Variable "
|
||||
env_header_underline = "| -------------------- "
|
||||
env_width = "20 "
|
||||
|
||||
table += f"{heading_level} {title}\n\n"
|
||||
|
||||
body = get_body(config)
|
||||
if body:
|
||||
table += body
|
||||
table += "\n\n"
|
||||
|
||||
table += (
|
||||
":::{table} "
|
||||
+ f"{'::'.join(toplevel_keys)}\n:widths: 10 {env_width}10 5 5 30\n:align: left\n\n"
|
||||
)
|
||||
table += f"| Name {env_header}| Type | Read-Only | Default | Description |\n"
|
||||
table += f"| ---- {env_header_underline}| ---- | --------- | ------- | ----------- |\n"
|
||||
|
||||
for field_name, field_info in list(config.model_fields.items()) + list(
|
||||
config.model_computed_fields.items()
|
||||
):
|
||||
regular_field = isinstance(field_info, FieldInfo)
|
||||
|
||||
config_name = field_name if extra_config else field_name.upper()
|
||||
field_type = field_info.annotation if regular_field else field_info.return_type
|
||||
default_value = get_default_value(field_info, regular_field)
|
||||
description = field_info.description if field_info.description else "-"
|
||||
deprecated = field_info.deprecated if field_info.deprecated else None
|
||||
read_only = "rw" if regular_field else "ro"
|
||||
type_name = get_type_name(field_type)
|
||||
|
||||
env_entry = ""
|
||||
if not extra_config:
|
||||
if regular_field:
|
||||
env_entry = f"| `{prefix}{config_name}` "
|
||||
else:
|
||||
env_entry = "| "
|
||||
if deprecated:
|
||||
if isinstance(deprecated, bool):
|
||||
description = "Deprecated!"
|
||||
else:
|
||||
description = deprecated
|
||||
table += f"| {field_name} {env_entry}| `{type_name}` | `{read_only}` | `{default_value}` | {description} |\n"
|
||||
|
||||
inner_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
|
||||
|
||||
def extract_nested_models(subtype: Any, subprefix: str, parent_types: list[str]):
|
||||
if subtype in inner_types.keys():
|
||||
return
|
||||
nested_types = resolve_nested_types(subtype, [])
|
||||
for nested_type, nested_parent_types in nested_types:
|
||||
if issubclass(nested_type, PydanticBaseModel):
|
||||
new_parent_types = parent_types + nested_parent_types
|
||||
if "list" in parent_types:
|
||||
new_prefix = ""
|
||||
else:
|
||||
new_prefix = f"{subprefix}"
|
||||
inner_types.setdefault(nested_type, (new_prefix, new_parent_types))
|
||||
for nested_field_name, nested_field_info in list(
|
||||
nested_type.model_fields.items()
|
||||
) + list(nested_type.model_computed_fields.items()):
|
||||
nested_field_type = nested_field_info.annotation
|
||||
if new_prefix:
|
||||
new_prefix += f"{nested_field_name.upper()}__"
|
||||
extract_nested_models(
|
||||
nested_field_type,
|
||||
new_prefix,
|
||||
new_parent_types + [nested_field_name],
|
||||
)
|
||||
|
||||
extract_nested_models(field_type, f"{prefix}{config_name}__", toplevel_keys + [field_name])
|
||||
|
||||
for new_type, info in inner_types.items():
|
||||
if new_type not in documented_types:
|
||||
undocumented_types.setdefault(new_type, (info[0], info[1]))
|
||||
|
||||
if toplevel:
|
||||
table = f"## {title}\n\n"
|
||||
table += ":::{table} " + f"{title}\n:widths: 10 10 5 5 30\n:align: left\n\n"
|
||||
table += "| Name | Type | Read-Only | Default | Description |\n"
|
||||
table += "| ---- | ---- | --------- | ------- | ----------- |\n"
|
||||
for name, config in sorted(configs.items()):
|
||||
type_name = config["type"]
|
||||
if type_name.startswith("typing."):
|
||||
type_name = type_name[len("typing.") :]
|
||||
table += f"| `{config['name']}` | `{type_name}` | `{config['read-only']}` | `{config['default']}` | {config['description']} |\n"
|
||||
table += ":::\n\n" # Add an empty line after the table
|
||||
|
||||
has_examples_list = toplevel_keys[-1] == "list"
|
||||
instance_list = create_model_from_examples(config, has_examples_list)
|
||||
if instance_list:
|
||||
ins_dict_list = []
|
||||
ins_out_dict_list = []
|
||||
for ins in instance_list:
|
||||
# Transform to JSON (and manually to dict) to use custom serializers and then merge with parent keys
|
||||
ins_json = ins.model_dump_json(include_computed_fields=False)
|
||||
ins_dict_list.append(json.loads(ins_json))
|
||||
|
||||
ins_out_json = ins.model_dump_json(include_computed_fields=True)
|
||||
ins_out_dict_list.append(json.loads(ins_out_json))
|
||||
|
||||
same_output = ins_out_dict_list == ins_dict_list
|
||||
same_output_str = "/Output" if same_output else ""
|
||||
|
||||
table += f"#{heading_level} Example Input{same_output_str}\n\n"
|
||||
table += "```{eval-rst}\n"
|
||||
table += ".. code-block:: json\n\n"
|
||||
if has_examples_list:
|
||||
input_dict = build_nested_structure(toplevel_keys[:-1], ins_dict_list)
|
||||
if not extra_config:
|
||||
global_config_dict[toplevel_keys[0]] = ins_dict_list
|
||||
else:
|
||||
input_dict = build_nested_structure(toplevel_keys, ins_dict_list[0])
|
||||
if not extra_config:
|
||||
global_config_dict[toplevel_keys[0]] = ins_dict_list[0]
|
||||
table += textwrap.indent(json.dumps(input_dict, indent=4), " ")
|
||||
table += "\n"
|
||||
table += "```\n\n"
|
||||
|
||||
if not same_output:
|
||||
table += f"#{heading_level} Example Output\n\n"
|
||||
table += "```{eval-rst}\n"
|
||||
table += ".. code-block:: json\n\n"
|
||||
if has_examples_list:
|
||||
output_dict = build_nested_structure(toplevel_keys[:-1], ins_out_dict_list)
|
||||
else:
|
||||
output_dict = build_nested_structure(toplevel_keys, ins_out_dict_list[0])
|
||||
table += textwrap.indent(json.dumps(output_dict, indent=4), " ")
|
||||
table += "\n"
|
||||
table += "```\n\n"
|
||||
|
||||
while undocumented_types:
|
||||
extra_config_type, extra_info = undocumented_types.popitem()
|
||||
documented_types.add(extra_config_type)
|
||||
table += generate_config_table_md(
|
||||
extra_config_type, extra_info[1], extra_info[0], True, True
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def generate_config_md(config_eos: ConfigEOS) -> str:
|
||||
def generate_config_md() -> str:
|
||||
"""Generate configuration specification in Markdown with extra tables for prefixed values.
|
||||
|
||||
Returns:
|
||||
str: The Markdown representation of the configuration spec.
|
||||
"""
|
||||
# Fix file path for general settings to not show local/test file path
|
||||
GeneralSettings._config_file_path = Path(
|
||||
"/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
|
||||
)
|
||||
GeneralSettings._config_folder_path = config_eos.general.config_file_path.parent
|
||||
configs = {}
|
||||
config_keys = config_eos.config_keys
|
||||
config_keys_read_only = config_eos.config_keys_read_only
|
||||
for config_key in config_keys:
|
||||
config = {}
|
||||
config["name"] = config_key
|
||||
config["value"] = getattr(config_eos, config_key)
|
||||
|
||||
if config_key in config_keys_read_only:
|
||||
config["read-only"] = "ro"
|
||||
computed_field_info = config_eos.__pydantic_decorators__.computed_fields[
|
||||
config_key
|
||||
].info
|
||||
config["default"] = "N/A"
|
||||
config["description"] = computed_field_info.description
|
||||
config["type"] = str(computed_field_info.return_type)
|
||||
else:
|
||||
config["read-only"] = "rw"
|
||||
field_info = config_eos.model_fields[config_key]
|
||||
config["default"] = field_info.default
|
||||
config["description"] = field_info.description
|
||||
config["type"] = str(field_info.annotation)
|
||||
|
||||
configs[config_key] = config
|
||||
|
||||
# Generate markdown for the main table
|
||||
markdown = "# Configuration Table\n\n"
|
||||
|
||||
# Generate tables for each top level config
|
||||
for field_name, field_info in config_eos.__class__.model_fields.items():
|
||||
field_type = field_info.annotation
|
||||
markdown += generate_config_table_md(
|
||||
field_type, [field_name], f"EOS_{field_name.upper()}__", True
|
||||
)
|
||||
# Generate table for general configuration names
|
||||
general_configs = {k: v for k, v in configs.items() if k in GENERAL_CONFIGS}
|
||||
for k in general_configs.keys():
|
||||
del configs[k] # Remove general configs from the main configs dictionary
|
||||
markdown += generate_config_table_md(general_configs, "General Configuration Values")
|
||||
|
||||
# Full config
|
||||
markdown += "## Full example Config\n\n"
|
||||
markdown += "```{eval-rst}\n"
|
||||
markdown += ".. code-block:: json\n\n"
|
||||
# Test for valid config first
|
||||
config_eos.merge_settings_from_dict(global_config_dict)
|
||||
markdown += textwrap.indent(json.dumps(global_config_dict, indent=4), " ")
|
||||
markdown += "\n"
|
||||
markdown += "```\n\n"
|
||||
non_prefixed_configs = {k: v for k, v in configs.items()}
|
||||
|
||||
# Assure there is no double \n at end of file
|
||||
# Generate tables for each prefix (sorted by value) and remove prefixed configs from the main dictionary
|
||||
sorted_prefixes = sorted(CONFIG_PREFIXES.items(), key=lambda item: item[1])
|
||||
for prefix, title in sorted_prefixes:
|
||||
prefixed_configs = {k: v for k, v in configs.items() if k.startswith(prefix)}
|
||||
for k in prefixed_configs.keys():
|
||||
del non_prefixed_configs[k]
|
||||
markdown += generate_config_table_md(prefixed_configs, title)
|
||||
|
||||
# Generate markdown for the remaining non-prefixed configs if any
|
||||
if non_prefixed_configs:
|
||||
markdown += generate_config_table_md(non_prefixed_configs, "Other Configuration Values")
|
||||
|
||||
# Assure the is no double \n at end of file
|
||||
markdown = markdown.rstrip("\n")
|
||||
markdown += "\n"
|
||||
|
||||
# Assure log path does not leak to documentation
|
||||
markdown = re.sub(
|
||||
r'(?<=["\'])/[^"\']*/output/eos\.log(?=["\'])',
|
||||
'/home/user/.local/share/net.akkudoktoreos.net/output/eos.log',
|
||||
markdown
|
||||
)
|
||||
|
||||
return markdown
|
||||
|
||||
|
||||
@@ -304,15 +145,12 @@ def main():
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
config_eos = get_config()
|
||||
|
||||
try:
|
||||
config_md = generate_config_md(config_eos)
|
||||
if os.name == "nt":
|
||||
config_md = config_md.replace("\\\\", "/")
|
||||
config_md = generate_config_md()
|
||||
if args.output_file:
|
||||
# Write to file
|
||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
||||
with open(args.output_file, "w", encoding="utf8") as f:
|
||||
f.write(config_md)
|
||||
else:
|
||||
# Write to std output
|
||||
@@ -320,8 +158,7 @@ def main():
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during Configuration Specification generation: {e}", file=sys.stderr)
|
||||
# keep throwing error to debug potential problems (e.g. invalid examples)
|
||||
raise e
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@@ -16,7 +16,6 @@ Example:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
@@ -38,14 +37,6 @@ def generate_openapi() -> dict:
|
||||
routes=app.routes,
|
||||
)
|
||||
|
||||
# Fix file path for general settings to not show local/test file path
|
||||
general = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["general"]["default"]
|
||||
general["config_file_path"] = "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
|
||||
general["config_folder_path"] = "/home/user/.config/net.akkudoktoreos.net"
|
||||
# Fix file path for logging settings to not show local/test file path
|
||||
logging = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["logging"]["default"]
|
||||
logging["file_path"] = "/home/user/.local/share/net.akkudoktoreos.net/output/eos.log"
|
||||
|
||||
return openapi_spec
|
||||
|
||||
|
||||
@@ -63,7 +54,7 @@ def main():
|
||||
openapi_spec_str = json.dumps(openapi_spec, indent=2)
|
||||
if args.output_file:
|
||||
# Write to file
|
||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
||||
with open(args.output_file, "w", encoding="utf8") as f:
|
||||
f.write(openapi_spec_str)
|
||||
else:
|
||||
# Write to std output
|
||||
|
@@ -3,7 +3,6 @@
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import git
|
||||
@@ -285,11 +284,9 @@ def main():
|
||||
|
||||
try:
|
||||
openapi_md = generate_openapi_md()
|
||||
if os.name == "nt":
|
||||
openapi_md = openapi_md.replace("127.0.0.1", "127.0.0.1")
|
||||
if args.output_file:
|
||||
# Write to file
|
||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
||||
with open(args.output_file, "w", encoding="utf8") as f:
|
||||
f.write(openapi_md)
|
||||
else:
|
||||
# Write to std output
|
||||
|
@@ -1 +0,0 @@
|
||||
# Placeholder for gitlint user rules (see https://jorisroovers.com/gitlint/latest/rules/user_defined_rules/).
|
@@ -12,12 +12,15 @@ import numpy as np
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.core.ems import get_ems
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.optimization.genetic import (
|
||||
OptimizationParameters,
|
||||
optimization_problem,
|
||||
)
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
|
||||
get_logger(__name__, logging_level="DEBUG")
|
||||
|
||||
|
||||
def prepare_optimization_real_parameters() -> OptimizationParameters:
|
||||
"""Prepare and return optimization parameters with real world data.
|
||||
@@ -27,63 +30,42 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
|
||||
"""
|
||||
# Make a config
|
||||
settings = {
|
||||
"general": {
|
||||
# -- General --
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
# -- Predictions --
|
||||
# PV Forecast
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
},
|
||||
],
|
||||
},
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
"pvforecast4_peakpower": None,
|
||||
# Weather Forecast
|
||||
"weather": {
|
||||
"provider": "ClearOutside",
|
||||
},
|
||||
"weather_provider": "ClearOutside",
|
||||
# Electricity Price Forecast
|
||||
"elecprice": {
|
||||
"provider": "ElecPriceAkkudoktor",
|
||||
},
|
||||
"elecprice_provider": "ElecPriceAkkudoktor",
|
||||
# Load Forecast
|
||||
"load": {
|
||||
"provider": "LoadAkkudoktor",
|
||||
"provider_settings": {
|
||||
"load_provider": "LoadAkkudoktor",
|
||||
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
|
||||
},
|
||||
},
|
||||
# -- Simulations --
|
||||
}
|
||||
config_eos = get_config()
|
||||
@@ -147,20 +129,20 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
|
||||
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
|
||||
},
|
||||
"pv_akku": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 26400,
|
||||
"initial_soc_percentage": 15,
|
||||
"min_soc_percentage": 15,
|
||||
},
|
||||
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery_id": "battery1"},
|
||||
"eauto": {
|
||||
"device_id": "ev1",
|
||||
"min_soc_percentage": 50,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.95,
|
||||
"max_charge_power_w": 11040,
|
||||
"initial_soc_percentage": 5,
|
||||
},
|
||||
"inverter": {
|
||||
"max_power_wh": 10000,
|
||||
},
|
||||
"temperature_forecast": temperature_forecast,
|
||||
"start_solution": start_solution,
|
||||
}
|
||||
@@ -301,20 +283,20 @@ def prepare_optimization_parameters() -> OptimizationParameters:
|
||||
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
|
||||
},
|
||||
"pv_akku": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 26400,
|
||||
"initial_soc_percentage": 15,
|
||||
"min_soc_percentage": 15,
|
||||
},
|
||||
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery_id": "battery1"},
|
||||
"eauto": {
|
||||
"device_id": "ev1",
|
||||
"min_soc_percentage": 50,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.95,
|
||||
"max_charge_power_w": 11040,
|
||||
"initial_soc_percentage": 5,
|
||||
},
|
||||
"inverter": {
|
||||
"max_power_wh": 10000,
|
||||
},
|
||||
"temperature_forecast": temperature_forecast,
|
||||
"start_solution": start_solution,
|
||||
}
|
||||
@@ -348,9 +330,7 @@ def run_optimization(
|
||||
|
||||
# Initialize the optimization problem using the default configuration
|
||||
config_eos = get_config()
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 48}, "optimization": {"hours": 48}}
|
||||
)
|
||||
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48})
|
||||
opt_class = optimization_problem(verbose=verbose, fixed_seed=seed)
|
||||
|
||||
# Perform the optimisation based on the provided parameters and start hour
|
||||
|
@@ -16,47 +16,32 @@ prediction_eos = get_prediction()
|
||||
def config_pvforecast() -> dict:
|
||||
"""Configure settings for PV forecast."""
|
||||
settings = {
|
||||
"general": {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
},
|
||||
],
|
||||
},
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
"pvforecast4_peakpower": None,
|
||||
}
|
||||
return settings
|
||||
|
||||
@@ -64,15 +49,10 @@ def config_pvforecast() -> dict:
|
||||
def config_weather() -> dict:
|
||||
"""Configure settings for weather forecast."""
|
||||
settings = {
|
||||
"general": {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"weather": dict(),
|
||||
}
|
||||
return settings
|
||||
|
||||
@@ -80,15 +60,10 @@ def config_weather() -> dict:
|
||||
def config_elecprice() -> dict:
|
||||
"""Configure settings for electricity price forecast."""
|
||||
settings = {
|
||||
"general": {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"elecprice": dict(),
|
||||
}
|
||||
return settings
|
||||
|
||||
@@ -96,14 +71,10 @@ def config_elecprice() -> dict:
|
||||
def config_load() -> dict:
|
||||
"""Configure settings for load forecast."""
|
||||
settings = {
|
||||
"general": {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
}
|
||||
return settings
|
||||
|
||||
@@ -121,40 +92,30 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
|
||||
# Initialize the oprediction
|
||||
config_eos = get_config()
|
||||
prediction_eos = get_prediction()
|
||||
if verbose:
|
||||
print(f"\nProvider ID: {provider_id}")
|
||||
if provider_id in ("PVForecastAkkudoktor",):
|
||||
settings = config_pvforecast()
|
||||
forecast = "pvforecast"
|
||||
settings["pvforecast_provider"] = provider_id
|
||||
elif provider_id in ("BrightSky", "ClearOutside"):
|
||||
settings = config_weather()
|
||||
forecast = "weather"
|
||||
settings["weather_provider"] = provider_id
|
||||
elif provider_id in ("ElecPriceAkkudoktor",):
|
||||
settings = config_elecprice()
|
||||
forecast = "elecprice"
|
||||
settings["elecprice_provider"] = provider_id
|
||||
elif provider_id in ("LoadAkkudoktor",):
|
||||
settings = config_elecprice()
|
||||
forecast = "load"
|
||||
settings["load"]["loadakkudoktor_year_energy"] = 1000
|
||||
settings["loadakkudoktor_year_energy"] = 1000
|
||||
settings["load_provider"] = provider_id
|
||||
else:
|
||||
raise ValueError(f"Unknown provider '{provider_id}'.")
|
||||
settings[forecast]["provider"] = provider_id
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
|
||||
provider = prediction_eos.provider_by_id(provider_id)
|
||||
|
||||
prediction_eos.update_data()
|
||||
|
||||
# Return result of prediction
|
||||
provider = prediction_eos.provider_by_id(provider_id)
|
||||
if verbose:
|
||||
print(f"\nProvider ID: {provider.provider_id()}")
|
||||
print("----------")
|
||||
print("\nSettings\n----------")
|
||||
print(settings)
|
||||
print("\nProvider\n----------")
|
||||
print(f"elecprice.provider: {config_eos.elecprice.provider}")
|
||||
print(f"load.provider: {config_eos.load.provider}")
|
||||
print(f"pvforecast.provider: {config_eos.pvforecast.provider}")
|
||||
print(f"weather.provider: {config_eos.weather.provider}")
|
||||
print(f"enabled: {provider.enabled()}")
|
||||
for key in provider.record_keys:
|
||||
print(f"\n{key}\n----------")
|
||||
print(f"Array: {provider.key_to_array(key)}")
|
||||
|
@@ -12,38 +12,34 @@ Key features:
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Optional, Type
|
||||
from typing import Any, ClassVar, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from platformdirs import user_config_dir, user_data_dir
|
||||
from pydantic import Field, computed_field
|
||||
from pydantic_settings import (
|
||||
BaseSettings,
|
||||
JsonConfigSettingsSource,
|
||||
PydanticBaseSettingsSource,
|
||||
SettingsConfigDict,
|
||||
)
|
||||
from pydantic import Field, ValidationError, computed_field
|
||||
|
||||
# settings
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.cachesettings import CacheCommonSettings
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
from akkudoktoreos.core.decorators import classproperty
|
||||
from akkudoktoreos.core.emsettings import EnergyManagementCommonSettings
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.logsettings import LoggingCommonSettings
|
||||
from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, merge_models
|
||||
from akkudoktoreos.devices.settings import DevicesCommonSettings
|
||||
from akkudoktoreos.devices.devices import DevicesCommonSettings
|
||||
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
|
||||
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
|
||||
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||
from akkudoktoreos.prediction.load import LoadCommonSettings
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import PredictionCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||
from akkudoktoreos.prediction.weather import WeatherCommonSettings
|
||||
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
||||
from akkudoktoreos.server.server import ServerCommonSettings
|
||||
from akkudoktoreos.utils.datetimeutil import to_timezone
|
||||
from akkudoktoreos.utils.utils import UtilsCommonSettings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_absolute_path(
|
||||
basepath: Optional[Path | str], subpath: Optional[Path | str]
|
||||
@@ -63,169 +59,61 @@ def get_absolute_path(
|
||||
return None
|
||||
|
||||
|
||||
class GeneralSettings(SettingsBaseModel):
|
||||
"""Settings for common configuration.
|
||||
|
||||
General configuration to set directories of cache and output files and system location (latitude
|
||||
and longitude).
|
||||
Validators ensure each parameter is within a specified range. A computed property, `timezone`,
|
||||
determines the time zone based on latitude and longitude.
|
||||
|
||||
Attributes:
|
||||
latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.
|
||||
longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.
|
||||
|
||||
Properties:
|
||||
timezone (Optional[str]): Computed time zone string based on the specified latitude
|
||||
and longitude.
|
||||
"""
|
||||
|
||||
_config_folder_path: ClassVar[Optional[Path]] = None
|
||||
_config_file_path: ClassVar[Optional[Path]] = None
|
||||
class ConfigCommonSettings(SettingsBaseModel):
|
||||
"""Settings for common configuration."""
|
||||
|
||||
data_folder_path: Optional[Path] = Field(
|
||||
default=None, description="Path to EOS data directory.", examples=[None, "/home/eos/data"]
|
||||
default=None, description="Path to EOS data directory."
|
||||
)
|
||||
|
||||
data_output_subpath: Optional[Path] = Field(
|
||||
default="output", description="Sub-path for the EOS output data directory."
|
||||
"output", description="Sub-path for the EOS output data directory."
|
||||
)
|
||||
|
||||
latitude: Optional[float] = Field(
|
||||
default=52.52,
|
||||
ge=-90.0,
|
||||
le=90.0,
|
||||
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
|
||||
)
|
||||
longitude: Optional[float] = Field(
|
||||
default=13.405,
|
||||
ge=-180.0,
|
||||
le=180.0,
|
||||
description="Longitude in decimal degrees, within -180 to 180 (°)",
|
||||
data_cache_subpath: Optional[Path] = Field(
|
||||
"cache", description="Sub-path for the EOS cache data directory."
|
||||
)
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def timezone(self) -> Optional[str]:
|
||||
"""Compute timezone based on latitude and longitude."""
|
||||
if self.latitude and self.longitude:
|
||||
return to_timezone(location=(self.latitude, self.longitude), as_string=True)
|
||||
return None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def data_output_path(self) -> Optional[Path]:
|
||||
"""Compute data_output_path based on data_folder_path."""
|
||||
return get_absolute_path(self.data_folder_path, self.data_output_subpath)
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_folder_path(self) -> Optional[Path]:
|
||||
"""Path to EOS configuration directory."""
|
||||
return self._config_folder_path
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_file_path(self) -> Optional[Path]:
|
||||
"""Path to EOS configuration file."""
|
||||
return self._config_file_path
|
||||
def data_cache_path(self) -> Optional[Path]:
|
||||
"""Compute data_cache_path based on data_folder_path."""
|
||||
return get_absolute_path(self.data_folder_path, self.data_cache_subpath)
|
||||
|
||||
|
||||
class SettingsEOS(BaseSettings, PydanticModelNestedValueMixin):
|
||||
"""Settings for all EOS.
|
||||
class SettingsEOS(
|
||||
ConfigCommonSettings,
|
||||
LoggingCommonSettings,
|
||||
DevicesCommonSettings,
|
||||
MeasurementCommonSettings,
|
||||
OptimizationCommonSettings,
|
||||
PredictionCommonSettings,
|
||||
ElecPriceCommonSettings,
|
||||
ElecPriceImportCommonSettings,
|
||||
LoadCommonSettings,
|
||||
LoadAkkudoktorCommonSettings,
|
||||
LoadImportCommonSettings,
|
||||
PVForecastCommonSettings,
|
||||
PVForecastImportCommonSettings,
|
||||
WeatherCommonSettings,
|
||||
WeatherImportCommonSettings,
|
||||
ServerCommonSettings,
|
||||
UtilsCommonSettings,
|
||||
):
|
||||
"""Settings for all EOS."""
|
||||
|
||||
Used by updating the configuration with specific settings only.
|
||||
"""
|
||||
|
||||
general: Optional[GeneralSettings] = Field(
|
||||
default=None,
|
||||
description="General Settings",
|
||||
)
|
||||
cache: Optional[CacheCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Cache Settings",
|
||||
)
|
||||
ems: Optional[EnergyManagementCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Energy Management Settings",
|
||||
)
|
||||
logging: Optional[LoggingCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Logging Settings",
|
||||
)
|
||||
devices: Optional[DevicesCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Devices Settings",
|
||||
)
|
||||
measurement: Optional[MeasurementCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Measurement Settings",
|
||||
)
|
||||
optimization: Optional[OptimizationCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Optimization Settings",
|
||||
)
|
||||
prediction: Optional[PredictionCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Prediction Settings",
|
||||
)
|
||||
elecprice: Optional[ElecPriceCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Electricity Price Settings",
|
||||
)
|
||||
load: Optional[LoadCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Load Settings",
|
||||
)
|
||||
pvforecast: Optional[PVForecastCommonSettings] = Field(
|
||||
default=None,
|
||||
description="PV Forecast Settings",
|
||||
)
|
||||
weather: Optional[WeatherCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Weather Settings",
|
||||
)
|
||||
server: Optional[ServerCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Server Settings",
|
||||
)
|
||||
utils: Optional[UtilsCommonSettings] = Field(
|
||||
default=None,
|
||||
description="Utilities Settings",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_nested_delimiter="__",
|
||||
nested_model_default_partial_update=True,
|
||||
env_prefix="EOS_",
|
||||
ignored_types=(classproperty,),
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
class SettingsEOSDefaults(SettingsEOS):
|
||||
"""Settings for all of EOS with defaults.
|
||||
|
||||
Used by ConfigEOS instance to make all fields available.
|
||||
"""
|
||||
|
||||
general: GeneralSettings = GeneralSettings()
|
||||
cache: CacheCommonSettings = CacheCommonSettings()
|
||||
ems: EnergyManagementCommonSettings = EnergyManagementCommonSettings()
|
||||
logging: LoggingCommonSettings = LoggingCommonSettings()
|
||||
devices: DevicesCommonSettings = DevicesCommonSettings()
|
||||
measurement: MeasurementCommonSettings = MeasurementCommonSettings()
|
||||
optimization: OptimizationCommonSettings = OptimizationCommonSettings()
|
||||
prediction: PredictionCommonSettings = PredictionCommonSettings()
|
||||
elecprice: ElecPriceCommonSettings = ElecPriceCommonSettings()
|
||||
load: LoadCommonSettings = LoadCommonSettings()
|
||||
pvforecast: PVForecastCommonSettings = PVForecastCommonSettings()
|
||||
weather: WeatherCommonSettings = WeatherCommonSettings()
|
||||
server: ServerCommonSettings = ServerCommonSettings()
|
||||
utils: UtilsCommonSettings = UtilsCommonSettings()
|
||||
|
||||
|
||||
class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
class ConfigEOS(SingletonMixin, SettingsEOS):
|
||||
"""Singleton configuration handler for the EOS application.
|
||||
|
||||
ConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic
|
||||
@@ -255,6 +143,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
in one part of the application reflects across all references to this class.
|
||||
|
||||
Attributes:
|
||||
_settings (ClassVar[SettingsEOS]): Holds application-wide settings.
|
||||
_file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file.
|
||||
config_folder_path (Optional[Path]): Path to the configuration directory.
|
||||
config_file_path (Optional[Path]): Path to the configuration file.
|
||||
|
||||
@@ -265,7 +155,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
To initialize and access configuration attributes (only one instance is created):
|
||||
```python
|
||||
config_eos = ConfigEOS() # Always returns the same instance
|
||||
print(config_eos.prediction.hours) # Access a setting from the loaded configuration
|
||||
print(config_eos.prediction_hours) # Access a setting from the loaded configuration
|
||||
```
|
||||
|
||||
"""
|
||||
@@ -277,141 +167,111 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
ENCODING: ClassVar[str] = "UTF-8"
|
||||
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
# ConfigEOS is a singleton
|
||||
return hash("config_eos")
|
||||
_settings: ClassVar[Optional[SettingsEOS]] = None
|
||||
_file_settings: ClassVar[Optional[SettingsEOS]] = None
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if not isinstance(other, ConfigEOS):
|
||||
return False
|
||||
# ConfigEOS is a singleton
|
||||
return True
|
||||
_config_folder_path: Optional[Path] = None
|
||||
_config_file_path: Optional[Path] = None
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: Type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||
"""Customizes the order and handling of settings sources for a Pydantic BaseSettings subclass.
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_folder_path(self) -> Optional[Path]:
|
||||
"""Path to EOS configuration directory."""
|
||||
return self._config_folder_path
|
||||
|
||||
This method determines the sources for application configuration settings, including
|
||||
environment variables, dotenv files and JSON configuration files.
|
||||
It ensures that a default configuration file exists and creates one if necessary.
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_file_path(self) -> Optional[Path]:
|
||||
"""Path to EOS configuration file."""
|
||||
return self._config_file_path
|
||||
|
||||
Args:
|
||||
settings_cls (Type[BaseSettings]): The Pydantic BaseSettings class for which sources are customized.
|
||||
init_settings (PydanticBaseSettingsSource): The initial settings source, typically passed at runtime.
|
||||
env_settings (PydanticBaseSettingsSource): Settings sourced from environment variables.
|
||||
dotenv_settings (PydanticBaseSettingsSource): Settings sourced from a dotenv file.
|
||||
file_secret_settings (PydanticBaseSettingsSource): Unused (needed for parent class interface).
|
||||
|
||||
Returns:
|
||||
tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
|
||||
|
||||
Behavior:
|
||||
1. Checks for the existence of a JSON configuration file in the expected location.
|
||||
2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a
|
||||
default configuration file to the location. If the copy fails, uses the default configuration file directly.
|
||||
3. Creates a `JsonConfigSettingsSource` for both the configuration file and the default configuration file.
|
||||
4. Updates class attributes `GeneralSettings._config_folder_path` and
|
||||
`GeneralSettings._config_file_path` to reflect the determined paths.
|
||||
5. Returns a tuple containing all provided and newly created settings sources in the desired order.
|
||||
|
||||
Notes:
|
||||
- This method logs a warning if the default configuration file cannot be copied.
|
||||
- It ensures that a fallback to the default configuration file is always possible.
|
||||
"""
|
||||
setting_sources = [
|
||||
init_settings,
|
||||
env_settings,
|
||||
dotenv_settings,
|
||||
]
|
||||
|
||||
file_settings: Optional[JsonConfigSettingsSource] = None
|
||||
config_file, exists = cls._get_config_file_path()
|
||||
config_dir = config_file.parent
|
||||
if not exists:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(cls.config_default_file_path, config_file)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not copy default config: {exc}. Using default config...")
|
||||
config_file = cls.config_default_file_path
|
||||
config_dir = config_file.parent
|
||||
try:
|
||||
file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file)
|
||||
setting_sources.append(file_settings)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error reading config file '{config_file}' (falling back to default config): {e}"
|
||||
)
|
||||
default_settings = JsonConfigSettingsSource(
|
||||
settings_cls, json_file=cls.config_default_file_path
|
||||
)
|
||||
GeneralSettings._config_folder_path = config_dir
|
||||
GeneralSettings._config_file_path = config_file
|
||||
|
||||
setting_sources.append(default_settings)
|
||||
return tuple(setting_sources)
|
||||
|
||||
@classproperty
|
||||
def config_default_file_path(cls) -> Path:
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_default_file_path(self) -> Path:
|
||||
"""Compute the default config file path."""
|
||||
return cls.package_root_path.joinpath("data/default.config.json")
|
||||
return self.package_root_path.joinpath("data/default.config.json")
|
||||
|
||||
@classproperty
|
||||
def package_root_path(cls) -> Path:
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def package_root_path(self) -> Path:
|
||||
"""Compute the package root path."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_keys(self) -> List[str]:
|
||||
"""Returns the keys of all fields in the configuration."""
|
||||
key_list = []
|
||||
key_list.extend(list(self.model_fields.keys()))
|
||||
key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys()))
|
||||
return key_list
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_keys_read_only(self) -> List[str]:
|
||||
"""Returns the keys of all read only fields in the configuration."""
|
||||
key_list = []
|
||||
key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys()))
|
||||
return key_list
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initializes the singleton ConfigEOS instance.
|
||||
|
||||
Configuration data is loaded from a configuration file or a default one is created if none
|
||||
exists.
|
||||
"""
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._setup(self, *args, **kwargs)
|
||||
super().__init__()
|
||||
self.from_config_file()
|
||||
self.update()
|
||||
|
||||
def _setup(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Re-initialize global settings."""
|
||||
# Check for config file content/ version type
|
||||
config_file, exists = self._get_config_file_path()
|
||||
if exists:
|
||||
with config_file.open("r", encoding="utf-8", newline=None) as f_config:
|
||||
config_txt = f_config.read()
|
||||
if '"directories": {' in config_txt or '"server_eos_host": ' in config_txt:
|
||||
error_msg = f"Configuration file '{config_file}' is outdated. Please remove or update manually."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
# Assure settings base knows EOS configuration
|
||||
SettingsBaseModel.config = self
|
||||
# (Re-)load settings
|
||||
SettingsEOSDefaults.__init__(self, *args, **kwargs)
|
||||
# Init config file and data folder pathes
|
||||
self._create_initial_config_file()
|
||||
self._update_data_folder_path()
|
||||
@property
|
||||
def settings(self) -> Optional[SettingsEOS]:
|
||||
"""Returns global settings for EOS.
|
||||
|
||||
def merge_settings(self, settings: SettingsEOS) -> None:
|
||||
Settings generally provide configuration for EOS and are typically set only once.
|
||||
|
||||
Returns:
|
||||
SettingsEOS: The settings for EOS or None.
|
||||
"""
|
||||
return ConfigEOS._settings
|
||||
|
||||
@classmethod
|
||||
def _merge_and_update_settings(cls, settings: SettingsEOS) -> None:
|
||||
"""Merge new and available settings.
|
||||
|
||||
Args:
|
||||
settings (SettingsEOS): The new settings to apply.
|
||||
"""
|
||||
for key in SettingsEOS.model_fields:
|
||||
if value := getattr(settings, key, None):
|
||||
setattr(cls._settings, key, value)
|
||||
|
||||
def merge_settings(self, settings: SettingsEOS, force: Optional[bool] = None) -> None:
|
||||
"""Merges the provided settings into the global settings for EOS, with optional overwrite.
|
||||
|
||||
Args:
|
||||
settings (SettingsEOS): The settings to apply globally.
|
||||
force (Optional[bool]): If True, overwrites the existing settings completely.
|
||||
If False, the new settings are merged to the existing ones with priority for
|
||||
the new ones. Defaults to False.
|
||||
|
||||
Raises:
|
||||
ValueError: If the `settings` is not a `SettingsEOS` instance.
|
||||
ValueError: If settings are already set and `force` is not True or
|
||||
if the `settings` is not a `SettingsEOS` instance.
|
||||
"""
|
||||
if not isinstance(settings, SettingsEOS):
|
||||
error_msg = f"Settings must be an instance of SettingsEOS: '{settings}'."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.")
|
||||
|
||||
self.merge_settings_from_dict(settings.model_dump(exclude_none=True, exclude_unset=True))
|
||||
if ConfigEOS._settings is None or force:
|
||||
ConfigEOS._settings = settings
|
||||
else:
|
||||
self._merge_and_update_settings(settings)
|
||||
|
||||
# Update configuration after merging
|
||||
self.update()
|
||||
|
||||
def merge_settings_from_dict(self, data: dict) -> None:
|
||||
"""Merges the provided dictionary data into the current instance.
|
||||
@@ -429,83 +289,141 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
|
||||
Example:
|
||||
>>> config = get_config()
|
||||
>>> new_data = {"prediction": {"hours": 24}, "server": {"port": 8000}}
|
||||
>>> new_data = {"prediction_hours": 24, "server_eos_port": 8000}
|
||||
>>> config.merge_settings_from_dict(new_data)
|
||||
"""
|
||||
self._setup(**merge_models(self, data))
|
||||
# Create new settings instance with reset optional fields and merged data
|
||||
settings = SettingsEOS.from_dict(data)
|
||||
self.merge_settings(settings)
|
||||
|
||||
def reset_settings(self) -> None:
|
||||
"""Reset all changed settings to environment/config file defaults.
|
||||
"""Reset all available settings.
|
||||
|
||||
This functions basically deletes the settings provided before.
|
||||
"""
|
||||
self._setup()
|
||||
|
||||
def _create_initial_config_file(self) -> None:
|
||||
if self.general.config_file_path and not self.general.config_file_path.exists():
|
||||
self.general.config_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(self.model_dump_json(indent=4))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Could not write configuration file '{self.general.config_file_path}': {e}"
|
||||
)
|
||||
ConfigEOS._settings = None
|
||||
|
||||
def _update_data_folder_path(self) -> None:
|
||||
"""Updates path to the data directory."""
|
||||
# From Settings
|
||||
if data_dir := self.general.data_folder_path:
|
||||
if self.settings and (data_dir := self.settings.data_folder_path):
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.general.data_folder_path = data_dir
|
||||
self.data_folder_path = data_dir
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not setup data dir: {e}")
|
||||
except:
|
||||
pass
|
||||
# From EOS_DIR env
|
||||
if env_dir := os.getenv(self.EOS_DIR):
|
||||
env_dir = os.getenv(self.EOS_DIR)
|
||||
if env_dir is not None:
|
||||
try:
|
||||
data_dir = Path(env_dir).resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.general.data_folder_path = data_dir
|
||||
self.data_folder_path = data_dir
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not setup data dir: {e}")
|
||||
except:
|
||||
pass
|
||||
# From configuration file
|
||||
if self._file_settings and (data_dir := self._file_settings.data_folder_path):
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.data_folder_path = data_dir
|
||||
return
|
||||
except:
|
||||
pass
|
||||
# From platform specific default path
|
||||
try:
|
||||
data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
|
||||
if data_dir is not None:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.general.data_folder_path = data_dir
|
||||
self.data_folder_path = data_dir
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not setup data dir: {e}")
|
||||
except:
|
||||
pass
|
||||
# Current working directory
|
||||
data_dir = Path.cwd()
|
||||
self.general.data_folder_path = data_dir
|
||||
self.data_folder_path = data_dir
|
||||
|
||||
@classmethod
|
||||
def _get_config_file_path(cls) -> tuple[Path, bool]:
|
||||
"""Find a valid configuration file or return the desired path for a new config file.
|
||||
def _get_config_file_path(self) -> tuple[Path, bool]:
|
||||
"""Finds the a valid configuration file or returns the desired path for a new config file.
|
||||
|
||||
Returns:
|
||||
tuple[Path, bool]: The path to the configuration file and if there is already a config file there
|
||||
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there
|
||||
"""
|
||||
config_dirs = []
|
||||
env_base_dir = os.getenv(cls.EOS_DIR)
|
||||
env_config_dir = os.getenv(cls.EOS_CONFIG_DIR)
|
||||
env_base_dir = os.getenv(self.EOS_DIR)
|
||||
env_config_dir = os.getenv(self.EOS_CONFIG_DIR)
|
||||
env_dir = get_absolute_path(env_base_dir, env_config_dir)
|
||||
logger.debug(f"Environment config dir: '{env_dir}'")
|
||||
logger.debug(f"Envionment config dir: '{env_dir}'")
|
||||
if env_dir is not None:
|
||||
config_dirs.append(env_dir.resolve())
|
||||
config_dirs.append(Path(user_config_dir(cls.APP_NAME, cls.APP_AUTHOR)))
|
||||
config_dirs.append(Path(user_config_dir(self.APP_NAME)))
|
||||
config_dirs.append(Path.cwd())
|
||||
for cdir in config_dirs:
|
||||
cfile = cdir.joinpath(cls.CONFIG_FILE_NAME)
|
||||
cfile = cdir.joinpath(self.CONFIG_FILE_NAME)
|
||||
if cfile.exists():
|
||||
logger.debug(f"Found config file: '{cfile}'")
|
||||
return cfile, True
|
||||
return config_dirs[0].joinpath(cls.CONFIG_FILE_NAME), False
|
||||
return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False
|
||||
|
||||
def settings_from_config_file(self) -> tuple[SettingsEOS, Path]:
|
||||
"""Load settings from the configuration file.
|
||||
|
||||
If the config file does not exist, it will be created.
|
||||
|
||||
Returns:
|
||||
tuple of settings and path
|
||||
settings (SettingsEOS): The settings defined by the EOS configuration file.
|
||||
path (pathlib.Path): The path of the configuration file.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration file is invalid or incomplete.
|
||||
"""
|
||||
config_file, exists = self._get_config_file_path()
|
||||
config_dir = config_file.parent
|
||||
|
||||
# Create config directory and copy default config if file does not exist
|
||||
if not exists:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(self.config_default_file_path, config_file)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not copy default config: {exc}. Using default config...")
|
||||
config_file = self.config_default_file_path
|
||||
config_dir = config_file.parent
|
||||
|
||||
# Load and validate the configuration file
|
||||
with config_file.open("r", encoding=self.ENCODING) as f_in:
|
||||
try:
|
||||
json_str = f_in.read()
|
||||
settings = SettingsEOS.model_validate_json(json_str)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Configuration '{config_file}' is incomplete or not valid: {exc}")
|
||||
|
||||
return settings, config_file
|
||||
|
||||
def from_config_file(self) -> tuple[SettingsEOS, Path]:
|
||||
"""Load the configuration file settings for EOS.
|
||||
|
||||
Returns:
|
||||
tuple of settings and path
|
||||
settings (SettingsEOS): The settings defined by the EOS configuration file.
|
||||
path (pathlib.Path): The path of the configuration file.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration file is invalid or incomplete.
|
||||
"""
|
||||
# Load settings from config file
|
||||
ConfigEOS._file_settings, config_file = self.settings_from_config_file()
|
||||
|
||||
# Update configuration in memory
|
||||
self.update()
|
||||
|
||||
# Everything worked, remember the values
|
||||
self._config_folder_path = config_file.parent
|
||||
self._config_file_path = config_file
|
||||
|
||||
return ConfigEOS._file_settings, config_file
|
||||
|
||||
def to_config_file(self) -> None:
|
||||
"""Saves the current configuration to the configuration file.
|
||||
@@ -515,24 +433,77 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
Raises:
|
||||
ValueError: If the configuration file path is not specified or can not be written to.
|
||||
"""
|
||||
if not self.general.config_file_path:
|
||||
if not self.config_file_path:
|
||||
raise ValueError("Configuration file path unknown.")
|
||||
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out:
|
||||
json_str = super().model_dump_json(indent=4)
|
||||
with self.config_file_path.open("w", encoding=self.ENCODING) as f_out:
|
||||
try:
|
||||
json_str = super().to_json()
|
||||
# Write to file
|
||||
f_out.write(json_str)
|
||||
# Also remember as actual settings
|
||||
ConfigEOS._file_settings = SettingsEOS.model_validate_json(json_str)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Could not update '{self.config_file_path}': {exc}")
|
||||
|
||||
def _config_value(self, key: str) -> Any:
|
||||
"""Retrieves the configuration value for a specific key, following a priority order.
|
||||
|
||||
Values are fetched in the following order:
|
||||
1. Settings.
|
||||
2. Environment variables.
|
||||
3. EOS configuration file.
|
||||
4. Current configuration.
|
||||
5. Field default constants.
|
||||
|
||||
Args:
|
||||
key (str): The configuration key to retrieve.
|
||||
|
||||
Returns:
|
||||
Any: The configuration value, or None if not found.
|
||||
"""
|
||||
# Settings
|
||||
if ConfigEOS._settings:
|
||||
if (value := getattr(self.settings, key, None)) is not None:
|
||||
return value
|
||||
|
||||
# Environment variables
|
||||
if (value := os.getenv(key)) is not None:
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
# EOS configuration file.
|
||||
if self._file_settings:
|
||||
if (value := getattr(self._file_settings, key, None)) is not None:
|
||||
return value
|
||||
|
||||
# Current configuration - key is valid as called by update().
|
||||
if (value := getattr(self, key, None)) is not None:
|
||||
return value
|
||||
|
||||
# Field default constants
|
||||
if (value := ConfigEOS.model_fields[key].default) is not None:
|
||||
return value
|
||||
|
||||
logger.debug(f"Value for configuration key '{key}' not found or is {value}")
|
||||
return None
|
||||
|
||||
def update(self) -> None:
|
||||
"""Updates all configuration fields.
|
||||
|
||||
This method updates all configuration fields using the following order for value retrieval:
|
||||
1. Current settings.
|
||||
1. Settings.
|
||||
2. Environment variables.
|
||||
3. EOS configuration file.
|
||||
4. Field default constants.
|
||||
4. Current configuration.
|
||||
5. Field default constants.
|
||||
|
||||
The first non None value in priority order is taken.
|
||||
"""
|
||||
self._setup(**self.model_dump())
|
||||
self._update_data_folder_path()
|
||||
for key in self.model_fields:
|
||||
setattr(self, key, self._config_value(key))
|
||||
|
||||
|
||||
def get_config() -> ConfigEOS:
|
||||
|
@@ -1,12 +1,13 @@
|
||||
"""Abstract and base classes for configuration."""
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
|
||||
|
||||
class SettingsBaseModel(PydanticBaseModel):
|
||||
"""Base model class for all settings configurations."""
|
||||
"""Base model class for all settings configurations.
|
||||
|
||||
# EOS configuration - set by ConfigEOS
|
||||
config: ClassVar[Any] = None
|
||||
Note:
|
||||
Settings property names shall be disjunctive to all existing settings' property names.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
@@ -1,32 +0,0 @@
|
||||
"""Settings for caching.
|
||||
|
||||
Kept in an extra module to avoid cyclic dependencies on package import.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
|
||||
|
||||
class CacheCommonSettings(SettingsBaseModel):
|
||||
"""Cache Configuration."""
|
||||
|
||||
subpath: Optional[Path] = Field(
|
||||
default="cache", description="Sub-path for the EOS cache data directory."
|
||||
)
|
||||
|
||||
cleanup_interval: float = Field(
|
||||
default=5 * 60, description="Intervall in seconds for EOS file cache cleanup."
|
||||
)
|
||||
|
||||
# Do not make this a pydantic computed field. The pydantic model must be fully initialized
|
||||
# to have access to config.general, which may not be the case if it is a computed field.
|
||||
def path(self) -> Optional[Path]:
|
||||
"""Compute cache path based on general.data_folder_path."""
|
||||
data_cache_path = self.config.general.data_folder_path
|
||||
if data_cache_path is None or self.subpath is None:
|
||||
return None
|
||||
return data_cache_path.joinpath(self.subpath)
|
@@ -13,10 +13,13 @@ Classes:
|
||||
import threading
|
||||
from typing import Any, ClassVar, Dict, Optional, Type
|
||||
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import computed_field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
config_eos: Any = None
|
||||
measurement_eos: Any = None
|
||||
prediction_eos: Any = None
|
||||
@@ -262,14 +265,6 @@ class SingletonMixin:
|
||||
class MySingletonModel(SingletonMixin, PydanticBaseModel):
|
||||
name: str
|
||||
|
||||
# implement __init__ to avoid re-initialization of parent classes:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
# Your initialisation here
|
||||
...
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
instance1 = MySingletonModel(name="Instance 1")
|
||||
instance2 = MySingletonModel(name="Instance 2")
|
||||
|
||||
|
@@ -19,7 +19,6 @@ from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union, over
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pendulum
|
||||
from loguru import logger
|
||||
from numpydantic import NDArray, Shape
|
||||
from pendulum import DateTime, Duration
|
||||
from pydantic import (
|
||||
@@ -32,6 +31,7 @@ from pydantic import (
|
||||
)
|
||||
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin, StartMixin
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import (
|
||||
PydanticBaseModel,
|
||||
PydanticDateTimeData,
|
||||
@@ -39,6 +39,8 @@ from akkudoktoreos.core.pydantic import (
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DataBase(ConfigMixin, StartMixin, PydanticBaseModel):
|
||||
"""Base class for handling generic data.
|
||||
@@ -809,8 +811,7 @@ class DataSequence(DataBase, MutableSequence):
|
||||
dates, values = self.key_to_lists(
|
||||
key=key, start_datetime=start_datetime, end_datetime=end_datetime, dropna=dropna
|
||||
)
|
||||
series = pd.Series(data=values, index=pd.DatetimeIndex(dates), name=key)
|
||||
return series
|
||||
return pd.Series(data=values, index=pd.DatetimeIndex(dates), name=key)
|
||||
|
||||
def key_from_series(self, key: str, series: pd.Series) -> None:
|
||||
"""Update the DataSequence from a Pandas Series.
|
||||
@@ -952,44 +953,6 @@ class DataSequence(DataBase, MutableSequence):
|
||||
array = resampled.values
|
||||
return array
|
||||
|
||||
def to_dataframe(
|
||||
self,
|
||||
start_datetime: Optional[DateTime] = None,
|
||||
end_datetime: Optional[DateTime] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Converts the sequence of DataRecord instances into a Pandas DataFrame.
|
||||
|
||||
Args:
|
||||
start_datetime (Optional[datetime]): The lower bound for filtering (inclusive).
|
||||
Defaults to the earliest possible datetime if None.
|
||||
end_datetime (Optional[datetime]): The upper bound for filtering (exclusive).
|
||||
Defaults to the latest possible datetime if None.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: A DataFrame containing the filtered data from all records.
|
||||
"""
|
||||
if not self.records:
|
||||
return pd.DataFrame() # Return empty DataFrame if no records exist
|
||||
|
||||
# Use filter_by_datetime to get filtered records
|
||||
filtered_records = self.filter_by_datetime(start_datetime, end_datetime)
|
||||
|
||||
# Convert filtered records to a dictionary list
|
||||
data = [record.model_dump() for record in filtered_records]
|
||||
|
||||
# Convert to DataFrame
|
||||
df = pd.DataFrame(data)
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# Ensure `date_time` column exists and use it for the index
|
||||
if not "date_time" in df.columns:
|
||||
error_msg = f"Cannot create dataframe: no `date_time` column in `{df}`."
|
||||
logger.error(error_msg)
|
||||
raise TypeError(error_msg)
|
||||
df.index = pd.DatetimeIndex(df["date_time"])
|
||||
return df
|
||||
|
||||
def sort_by_datetime(self, reverse: bool = False) -> None:
|
||||
"""Sort the DataRecords in the sequence by their date_time attribute.
|
||||
|
||||
@@ -1147,7 +1110,7 @@ class DataProvider(SingletonMixin, DataSequence):
|
||||
|
||||
To be implemented by derived classes.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
return self.provider_id() == self.config.abstract_provider
|
||||
|
||||
@abstractmethod
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
@@ -1158,11 +1121,6 @@ class DataProvider(SingletonMixin, DataSequence):
|
||||
"""
|
||||
pass
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def update_data(
|
||||
self,
|
||||
force_enable: Optional[bool] = False,
|
||||
@@ -1266,14 +1224,14 @@ class DataImportMixin:
|
||||
# We jump back by 1 hour
|
||||
# Repeat the value(s) (reuse value index)
|
||||
for i in range(interval_steps_per_hour):
|
||||
logger.debug(f"{i + 1}: Repeat at {next_time} with index {value_index}")
|
||||
logger.debug(f"{i+1}: Repeat at {next_time} with index {value_index}")
|
||||
timestamps_with_indices.append((next_time, value_index))
|
||||
next_time = next_time.add(seconds=interval.total_seconds())
|
||||
else:
|
||||
# We jump forward by 1 hour
|
||||
# Drop the value(s)
|
||||
logger.debug(
|
||||
f"{i + 1}: Skip {interval_steps_per_hour} at {next_time} with index {value_index}"
|
||||
f"{i+1}: Skip {interval_steps_per_hour} at {next_time} with index {value_index}"
|
||||
)
|
||||
value_index += interval_steps_per_hour
|
||||
|
||||
@@ -1502,7 +1460,7 @@ class DataImportMixin:
|
||||
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
|
||||
logger.debug(f"PydanticDateTimeDataFrame import: {error_msg}")
|
||||
|
||||
# Try dictionary with special keys start_datetime and interval
|
||||
# Try dictionary with special keys start_datetime and intervall
|
||||
try:
|
||||
import_data = PydanticDateTimeData.model_validate_json(json_str)
|
||||
self.import_from_dict(import_data.to_dict())
|
||||
@@ -1562,7 +1520,7 @@ class DataImportMixin:
|
||||
and `key_prefix = "load"`, only the "load_mean" key will be processed even though
|
||||
both keys are in the record.
|
||||
"""
|
||||
with import_file_path.open("r", encoding="utf-8", newline=None) as import_file:
|
||||
with import_file_path.open("r") as import_file:
|
||||
import_str = import_file.read()
|
||||
self.import_from_json(
|
||||
import_str, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval
|
||||
@@ -1637,11 +1595,6 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
|
||||
)
|
||||
return list(key_set)
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __getitem__(self, key: str) -> pd.Series:
|
||||
"""Retrieve a Pandas Series for a specified key from the data in each DataProvider.
|
||||
|
||||
@@ -1844,88 +1797,6 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
|
||||
|
||||
return array
|
||||
|
||||
def keys_to_dataframe(
|
||||
self,
|
||||
keys: list[str],
|
||||
start_datetime: Optional[DateTime] = None,
|
||||
end_datetime: Optional[DateTime] = None,
|
||||
interval: Optional[Any] = None, # Duration assumed
|
||||
fill_method: Optional[str] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Retrieve a dataframe indexed by fixed time intervals for specified keys from the data in each DataProvider.
|
||||
|
||||
Generates a pandas DataFrame using the NumPy arrays for each specified key, ensuring a common time index..
|
||||
|
||||
Args:
|
||||
keys (list[str]): A list of field names to retrieve.
|
||||
start_datetime (datetime, optional): Start date for filtering records (inclusive).
|
||||
end_datetime (datetime, optional): End date for filtering records (exclusive).
|
||||
interval (duration, optional): The fixed time interval. Defaults to 1 hour.
|
||||
fill_method (str, optional): Method to handle missing values during resampling.
|
||||
- 'linear': Linearly interpolate missing values (for numeric data only).
|
||||
- 'ffill': Forward fill missing values.
|
||||
- 'bfill': Backward fill missing values.
|
||||
- 'none': Defaults to 'linear' for numeric values, otherwise 'ffill'.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: A DataFrame where each column represents a key's array with a common time index.
|
||||
|
||||
Raises:
|
||||
KeyError: If no valid data is found for any of the requested keys.
|
||||
ValueError: If any retrieved array has a different time index than the first one.
|
||||
"""
|
||||
# Ensure datetime objects are normalized
|
||||
start_datetime = to_datetime(start_datetime, to_maxtime=False) if start_datetime else None
|
||||
end_datetime = to_datetime(end_datetime, to_maxtime=False) if end_datetime else None
|
||||
if interval is None:
|
||||
interval = to_duration("1 hour")
|
||||
if start_datetime is None:
|
||||
# Take earliest datetime of all providers that are enabled
|
||||
for provider in self.enabled_providers:
|
||||
if start_datetime is None:
|
||||
start_datetime = provider.min_datetime
|
||||
elif (
|
||||
provider.min_datetime
|
||||
and compare_datetimes(provider.min_datetime, start_datetime).lt
|
||||
):
|
||||
start_datetime = provider.min_datetime
|
||||
if end_datetime is None:
|
||||
# Take latest datetime of all providers that are enabled
|
||||
for provider in self.enabled_providers:
|
||||
if end_datetime is None:
|
||||
end_datetime = provider.max_datetime
|
||||
elif (
|
||||
provider.max_datetime
|
||||
and compare_datetimes(provider.max_datetime, end_datetime).gt
|
||||
):
|
||||
end_datetime = provider.min_datetime
|
||||
if end_datetime:
|
||||
end_datetime.add(seconds=1)
|
||||
|
||||
# Create a DatetimeIndex based on start, end, and interval
|
||||
reference_index = pd.date_range(
|
||||
start=start_datetime, end=end_datetime, freq=interval, inclusive="left"
|
||||
)
|
||||
|
||||
data = {}
|
||||
for key in keys:
|
||||
try:
|
||||
array = self.key_to_array(key, start_datetime, end_datetime, interval, fill_method)
|
||||
|
||||
if len(array) != len(reference_index):
|
||||
raise ValueError(
|
||||
f"Array length mismatch for key '{key}' (expected {len(reference_index)}, got {len(array)})"
|
||||
)
|
||||
|
||||
data[key] = array
|
||||
except KeyError as e:
|
||||
raise KeyError(f"Failed to retrieve data for key '{key}': {e}")
|
||||
|
||||
if not data:
|
||||
raise KeyError(f"No valid data found for the requested keys {keys}.")
|
||||
|
||||
return pd.DataFrame(data, index=reference_index)
|
||||
|
||||
def provider_by_id(self, provider_id: str) -> DataProvider:
|
||||
"""Retrieves a data provider by its unique identifier.
|
||||
|
||||
|
@@ -1,44 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class classproperty:
|
||||
"""A decorator to define a read-only property at the class level.
|
||||
|
||||
This class replaces the built-in `property` which is no longer available in
|
||||
combination with @classmethod since Python 3.13 to allow a method to be
|
||||
accessed as a property on the class itself, rather than an instance. This
|
||||
is useful when you want a property-like syntax for methods that depend on
|
||||
the class rather than any instance of the class.
|
||||
|
||||
Example:
|
||||
class MyClass:
|
||||
_value = 42
|
||||
|
||||
@classproperty
|
||||
def value(cls):
|
||||
return cls._value
|
||||
|
||||
print(MyClass.value) # Outputs: 42
|
||||
|
||||
Methods:
|
||||
__get__: Retrieves the value of the class property by calling the
|
||||
decorated method on the class.
|
||||
|
||||
Parameters:
|
||||
fget (Callable[[Any], Any]): A method that takes the class as an
|
||||
argument and returns a value.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If `fget` is not defined when `__get__` is called.
|
||||
"""
|
||||
|
||||
def __init__(self, fget: Callable[[Any], Any]) -> None:
|
||||
self.fget = fget
|
||||
|
||||
def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any:
|
||||
if owner_cls is None:
|
||||
return self
|
||||
if self.fget is None:
|
||||
raise RuntimeError("'fget' not defined when `__get__` is called")
|
||||
return self.fget(owner_cls)
|
@@ -1,24 +1,24 @@
|
||||
import traceback
|
||||
from typing import Any, ClassVar, Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from numpydantic import NDArray, Shape
|
||||
from pendulum import DateTime
|
||||
from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from akkudoktoreos.core.cache import CacheUntilUpdateStore
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel, PydanticBaseModel
|
||||
from akkudoktoreos.devices.battery import Battery
|
||||
from akkudoktoreos.devices.generic import HomeAppliance
|
||||
from akkudoktoreos.devices.inverter import Inverter
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class EnergyManagementParameters(ParametersBaseModel):
|
||||
|
||||
class EnergieManagementSystemParameters(ParametersBaseModel):
|
||||
pv_prognose_wh: list[float] = Field(
|
||||
description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
|
||||
)
|
||||
@@ -107,7 +107,7 @@ class SimulationResult(ParametersBaseModel):
|
||||
return NumpyEncoder.convert_numpy(field)[0]
|
||||
|
||||
|
||||
class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel):
|
||||
class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel):
|
||||
# Disable validation on assignment to speed up simulation runs.
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=False,
|
||||
@@ -116,33 +116,16 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
# Start datetime.
|
||||
_start_datetime: ClassVar[Optional[DateTime]] = None
|
||||
|
||||
# last run datetime. Used by energy management task
|
||||
_last_datetime: ClassVar[Optional[DateTime]] = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def start_datetime(self) -> DateTime:
|
||||
"""The starting datetime of the current or latest energy management."""
|
||||
if EnergyManagement._start_datetime is None:
|
||||
EnergyManagement.set_start_datetime()
|
||||
return EnergyManagement._start_datetime
|
||||
if EnergieManagementSystem._start_datetime is None:
|
||||
EnergieManagementSystem.set_start_datetime()
|
||||
return EnergieManagementSystem._start_datetime
|
||||
|
||||
@classmethod
|
||||
def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime:
|
||||
"""Set the start datetime for the next energy management cycle.
|
||||
|
||||
If no datetime is provided, the current datetime is used.
|
||||
|
||||
The start datetime is always rounded down to the nearest hour
|
||||
(i.e., setting minutes, seconds, and microseconds to zero).
|
||||
|
||||
Args:
|
||||
start_datetime (Optional[DateTime]): The datetime to set as the start.
|
||||
If None, the current datetime is used.
|
||||
|
||||
Returns:
|
||||
DateTime: The adjusted start datetime.
|
||||
"""
|
||||
if start_datetime is None:
|
||||
start_datetime = to_datetime()
|
||||
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
|
||||
@@ -186,14 +169,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
|
||||
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_parameters(
|
||||
self,
|
||||
parameters: EnergyManagementParameters,
|
||||
parameters: EnergieManagementSystemParameters,
|
||||
ev: Optional[Battery] = None,
|
||||
home_appliance: Optional[HomeAppliance] = None,
|
||||
inverter: Optional[Inverter] = None,
|
||||
@@ -215,9 +193,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
self.ev = ev
|
||||
self.home_appliance = home_appliance
|
||||
self.inverter = inverter
|
||||
self.ac_charge_hours = np.full(self.config.prediction.hours, 0.0)
|
||||
self.dc_charge_hours = np.full(self.config.prediction.hours, 1.0)
|
||||
self.ev_charge_hours = np.full(self.config.prediction.hours, 0.0)
|
||||
self.ac_charge_hours = np.full(self.config.prediction_hours, 0.0)
|
||||
self.dc_charge_hours = np.full(self.config.prediction_hours, 1.0)
|
||||
self.ev_charge_hours = np.full(self.config.prediction_hours, 0.0)
|
||||
|
||||
def set_akku_discharge_hours(self, ds: np.ndarray) -> None:
|
||||
if self.battery:
|
||||
@@ -260,88 +238,26 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
is mostly relevant to prediction providers.
|
||||
force_update (bool, optional): If True, forces to update the data even if still cached.
|
||||
"""
|
||||
# Throw away any cached results of the last run.
|
||||
CacheUntilUpdateStore().clear()
|
||||
self.set_start_hour(start_hour=start_hour)
|
||||
self.config.update()
|
||||
|
||||
# Check for run definitions
|
||||
if self.start_datetime is None:
|
||||
error_msg = "Start datetime unknown."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
if self.config.prediction.hours is None:
|
||||
if self.config.prediction_hours is None:
|
||||
error_msg = "Prediction hours unknown."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
if self.config.optimization.hours is None:
|
||||
error_msg = "Optimization hours unknown."
|
||||
if self.config.optimisation_hours is None:
|
||||
error_msg = "Optimisation hours unknown."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
self.prediction.update_data(force_enable=force_enable, force_update=force_update)
|
||||
# TODO: Create optimisation problem that calls into devices.update_data() for simulations.
|
||||
|
||||
logger.info("Energy management run (crippled version - prediction update only)")
|
||||
|
||||
def manage_energy(self) -> None:
|
||||
"""Repeating task for managing energy.
|
||||
|
||||
This task should be executed by the server regularly (e.g., every 10 seconds)
|
||||
to ensure proper energy management. Configuration changes to the energy management interval
|
||||
will only take effect if this task is executed.
|
||||
|
||||
- Initializes and runs the energy management for the first time if it has never been run
|
||||
before.
|
||||
- If the energy management interval is not configured or invalid (NaN), the task will not
|
||||
trigger any repeated energy management runs.
|
||||
- Compares the current time with the last run time and runs the energy management if the
|
||||
interval has elapsed.
|
||||
- Logs any exceptions that occur during the initialization or execution of the energy
|
||||
management.
|
||||
|
||||
Note: The task maintains the interval even if some intervals are missed.
|
||||
"""
|
||||
current_datetime = to_datetime()
|
||||
interval = self.config.ems.interval # interval maybe changed in between
|
||||
|
||||
if EnergyManagement._last_datetime is None:
|
||||
# Never run before
|
||||
try:
|
||||
# Remember energy run datetime.
|
||||
EnergyManagement._last_datetime = current_datetime
|
||||
# Try to run a first energy management. May fail due to config incomplete.
|
||||
self.run()
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
message = f"EOS init: {e}\n{trace}"
|
||||
logger.error(message)
|
||||
return
|
||||
|
||||
if interval is None or interval == float("nan"):
|
||||
# No Repetition
|
||||
return
|
||||
|
||||
if (
|
||||
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
|
||||
< interval
|
||||
):
|
||||
# Wait for next run
|
||||
return
|
||||
|
||||
try:
|
||||
self.run()
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
message = f"EOS run: {e}\n{trace}"
|
||||
logger.error(message)
|
||||
|
||||
# Remember the energy management run - keep on interval even if we missed some intervals
|
||||
while (
|
||||
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
|
||||
>= interval
|
||||
):
|
||||
EnergyManagement._last_datetime = EnergyManagement._last_datetime.add(seconds=interval)
|
||||
|
||||
def set_start_hour(self, start_hour: Optional[int] = None) -> None:
|
||||
"""Sets start datetime to given hour.
|
||||
|
||||
@@ -394,8 +310,7 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
|
||||
# Fetch objects
|
||||
battery = self.battery
|
||||
if battery is None:
|
||||
raise ValueError(f"battery not set: {battery}")
|
||||
assert battery # to please mypy
|
||||
ev = self.ev
|
||||
home_appliance = self.home_appliance
|
||||
inverter = self.inverter
|
||||
@@ -520,9 +435,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
|
||||
|
||||
# Initialize the Energy Management System, it is a singleton.
|
||||
ems = EnergyManagement()
|
||||
ems = EnergieManagementSystem()
|
||||
|
||||
|
||||
def get_ems() -> EnergyManagement:
|
||||
def get_ems() -> EnergieManagementSystem:
|
||||
"""Gets the EOS Energy Management System."""
|
||||
return ems
|
||||
|
@@ -1,26 +0,0 @@
|
||||
"""Settings for energy management.
|
||||
|
||||
Kept in an extra module to avoid cyclic dependencies on package import.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
|
||||
|
||||
class EnergyManagementCommonSettings(SettingsBaseModel):
|
||||
"""Energy Management Configuration."""
|
||||
|
||||
startup_delay: float = Field(
|
||||
default=5,
|
||||
ge=1,
|
||||
description="Startup delay in seconds for EOS energy management runs.",
|
||||
)
|
||||
|
||||
interval: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Intervall in seconds between EOS energy management runs.",
|
||||
examples=["300"],
|
||||
)
|
@@ -1,3 +1,20 @@
|
||||
"""Abstract and base classes for logging."""
|
||||
|
||||
LOGGING_LEVELS: list[str] = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
import logging
|
||||
|
||||
|
||||
def logging_str_to_level(level_str: str) -> int:
|
||||
"""Convert log level string to logging level."""
|
||||
if level_str == "DEBUG":
|
||||
level = logging.DEBUG
|
||||
elif level_str == "INFO":
|
||||
level = logging.INFO
|
||||
elif level_str == "WARNING":
|
||||
level = logging.WARNING
|
||||
elif level_str == "CRITICAL":
|
||||
level = logging.CRITICAL
|
||||
elif level_str == "ERROR":
|
||||
level = logging.ERROR
|
||||
else:
|
||||
raise ValueError(f"Unknown loggin level: {level_str}")
|
||||
return level
|
||||
|
@@ -1,241 +1,91 @@
|
||||
"""Utility for configuring Loguru loggers."""
|
||||
"""Utility functions for handling logging tasks.
|
||||
|
||||
Functions:
|
||||
----------
|
||||
- get_logger: Creates and configures a logger with console and optional rotating file logging.
|
||||
|
||||
Example usage:
|
||||
--------------
|
||||
# Logger setup
|
||||
>>> logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
|
||||
>>> logger.info("Logging initialized.")
|
||||
|
||||
Notes:
|
||||
------
|
||||
- The logger supports rotating log files to prevent excessive log file size.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging as pylogging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
from typing import Any, List, Optional
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
import pendulum
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
from akkudoktoreos.core.logabc import logging_str_to_level
|
||||
|
||||
|
||||
class InterceptHandler(pylogging.Handler):
|
||||
"""A logging handler that redirects standard Python logging messages to Loguru.
|
||||
def get_logger(
|
||||
name: str,
|
||||
log_file: Optional[str] = None,
|
||||
logging_level: Optional[str] = None,
|
||||
max_bytes: int = 5000000,
|
||||
backup_count: int = 5,
|
||||
) -> pylogging.Logger:
|
||||
"""Creates and configures a logger with a given name.
|
||||
|
||||
This handler ensures consistency between the `logging` module and Loguru by intercepting
|
||||
logs sent to the standard logging system and re-emitting them through Loguru with proper
|
||||
formatting and context (including exception info and call depth).
|
||||
|
||||
Attributes:
|
||||
loglevel_mapping (dict): Mapping from standard logging levels to Loguru level names.
|
||||
"""
|
||||
|
||||
loglevel_mapping: dict[int, str] = {
|
||||
50: "CRITICAL",
|
||||
40: "ERROR",
|
||||
30: "WARNING",
|
||||
20: "INFO",
|
||||
10: "DEBUG",
|
||||
5: "TRACE",
|
||||
0: "NOTSET",
|
||||
}
|
||||
|
||||
def emit(self, record: pylogging.LogRecord) -> None:
|
||||
"""Emits a logging record by forwarding it to Loguru with preserved metadata.
|
||||
The logger supports logging to both the console and an optional log file. File logging is
|
||||
handled by a rotating file handler to prevent excessive log file size.
|
||||
|
||||
Args:
|
||||
record (logging.LogRecord): A record object containing log message and metadata.
|
||||
"""
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except AttributeError:
|
||||
level = self.loglevel_mapping.get(record.levelno, "INFO")
|
||||
|
||||
frame: Optional[FrameType] = pylogging.currentframe()
|
||||
depth: int = 2
|
||||
while frame and frame.f_code.co_filename == pylogging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
log = logger.bind(request_id="app")
|
||||
log.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
console_handler_id = None
|
||||
file_handler_id = None
|
||||
|
||||
|
||||
def track_logging_config(config_eos: Any, path: str, old_value: Any, value: Any) -> None:
|
||||
"""Track logging config changes."""
|
||||
global console_handler_id, file_handler_id
|
||||
|
||||
if not path.startswith("logging"):
|
||||
raise ValueError(f"Logging shall not track '{path}'")
|
||||
|
||||
if not config_eos.logging.console_level:
|
||||
# No value given - check environment value - may also be None
|
||||
config_eos.logging.console_level = os.getenv("EOS_LOGGING__LEVEL")
|
||||
if not config_eos.logging.file_level:
|
||||
# No value given - check environment value - may also be None
|
||||
config_eos.logging.file_level = os.getenv("EOS_LOGGING__LEVEL")
|
||||
|
||||
# Remove handlers
|
||||
if console_handler_id:
|
||||
try:
|
||||
logger.remove(console_handler_id)
|
||||
except Exception as e:
|
||||
logger.debug("Exception on logger.remove: {}", e, exc_info=True)
|
||||
console_handler_id = None
|
||||
if file_handler_id:
|
||||
try:
|
||||
logger.remove(file_handler_id)
|
||||
except Exception as e:
|
||||
logger.debug("Exception on logger.remove: {}", e, exc_info=True)
|
||||
file_handler_id = None
|
||||
|
||||
# Create handlers with new configuration
|
||||
# Always add console handler
|
||||
if config_eos.logging.console_level not in LOGGING_LEVELS:
|
||||
logger.error(
|
||||
f"Invalid console log level '{config_eos.logging.console_level} - forced to INFO'."
|
||||
)
|
||||
config_eos.logging.console_level = "INFO"
|
||||
|
||||
console_handler_id = logger.add(
|
||||
sys.stderr,
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
level=config_eos.logging.console_level,
|
||||
# format=_console_format
|
||||
)
|
||||
|
||||
# Add file handler
|
||||
if config_eos.logging.file_level and config_eos.logging.file_path:
|
||||
if config_eos.logging.file_level not in LOGGING_LEVELS:
|
||||
logger.error(
|
||||
f"Invalid file log level '{config_eos.logging.console_level}' - forced to INFO."
|
||||
)
|
||||
config_eos.logging.file_level = "INFO"
|
||||
|
||||
file_handler_id = logger.add(
|
||||
sink=config_eos.logging.file_path,
|
||||
rotation="100 MB",
|
||||
retention="3 days",
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
level=config_eos.logging.file_level,
|
||||
serialize=True, # JSON dict formatting
|
||||
# format=_file_format
|
||||
)
|
||||
|
||||
# Redirect standard logging to Loguru
|
||||
pylogging.basicConfig(handlers=[InterceptHandler()], level=0)
|
||||
# Redirect uvicorn and fastapi logging to Loguru
|
||||
pylogging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||
for pylogger_name in ["uvicorn", "uvicorn.error", "fastapi"]:
|
||||
pylogger = pylogging.getLogger(pylogger_name)
|
||||
pylogger.handlers = [InterceptHandler()]
|
||||
pylogger.propagate = False
|
||||
|
||||
logger.info(
|
||||
f"Logger reconfigured - console: {config_eos.logging.console_level}, file: {config_eos.logging.file_level}."
|
||||
)
|
||||
|
||||
|
||||
def read_file_log(
|
||||
log_path: Path,
|
||||
limit: int = 100,
|
||||
level: Optional[str] = None,
|
||||
contains: Optional[str] = None,
|
||||
regex: Optional[str] = None,
|
||||
from_time: Optional[str] = None,
|
||||
to_time: Optional[str] = None,
|
||||
tail: bool = False,
|
||||
) -> List[dict]:
|
||||
"""Read and filter structured log entries from a JSON-formatted log file.
|
||||
|
||||
Args:
|
||||
log_path (Path): Path to the JSON-formatted log file.
|
||||
limit (int, optional): Maximum number of log entries to return. Defaults to 100.
|
||||
level (Optional[str], optional): Filter logs by log level (e.g., "INFO", "ERROR"). Defaults to None.
|
||||
contains (Optional[str], optional): Filter logs that contain this substring in their message. Case-insensitive. Defaults to None.
|
||||
regex (Optional[str], optional): Filter logs whose message matches this regular expression. Defaults to None.
|
||||
from_time (Optional[str], optional): ISO 8601 datetime string to filter logs not earlier than this time. Defaults to None.
|
||||
to_time (Optional[str], optional): ISO 8601 datetime string to filter logs not later than this time. Defaults to None.
|
||||
tail (bool, optional): If True, read the last lines of the file (like `tail -n`). Defaults to False.
|
||||
name (str): The name of the logger, typically `__name__` from the calling module.
|
||||
log_file (Optional[str]): Path to the log file for file logging. If None, no file logging is done.
|
||||
logging_level (Optional[str]): Logging level (e.g., "INFO", "DEBUG"). Defaults to "INFO".
|
||||
max_bytes (int): Maximum size in bytes for log file before rotation. Defaults to 5 MB.
|
||||
backup_count (int): Number of backup log files to keep. Defaults to 5.
|
||||
|
||||
Returns:
|
||||
List[dict]: A list of filtered log entries as dictionaries.
|
||||
logging.Logger: Configured logger instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the log file does not exist.
|
||||
ValueError: If the datetime strings are invalid or improperly formatted.
|
||||
Exception: For other unforeseen I/O or parsing errors.
|
||||
Example:
|
||||
logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
|
||||
logger.info("Application started")
|
||||
"""
|
||||
if not log_path.exists():
|
||||
raise FileNotFoundError("Log file not found")
|
||||
# Create a logger with the specified name
|
||||
logger = pylogging.getLogger(name)
|
||||
logger.propagate = True
|
||||
if logging_level is not None:
|
||||
level = logging_str_to_level(logging_level)
|
||||
logger.setLevel(level)
|
||||
|
||||
try:
|
||||
from_dt = pendulum.parse(from_time) if from_time else None
|
||||
to_dt = pendulum.parse(to_time) if to_time else None
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid date/time format: {e}")
|
||||
# The log message format
|
||||
formatter = pylogging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
regex_pattern = re.compile(regex) if regex else None
|
||||
# Prevent loggers from being added multiple times
|
||||
# There may already be a logger from pytest
|
||||
if not logger.handlers:
|
||||
# Create a console handler with a standard output stream
|
||||
console_handler = pylogging.StreamHandler()
|
||||
if logging_level is not None:
|
||||
console_handler.setLevel(level)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
def matches_filters(log: dict) -> bool:
|
||||
if level and log.get("level", {}).get("name") != level.upper():
|
||||
return False
|
||||
if contains and contains.lower() not in log.get("message", "").lower():
|
||||
return False
|
||||
if regex_pattern and not regex_pattern.search(log.get("message", "")):
|
||||
return False
|
||||
if from_dt or to_dt:
|
||||
try:
|
||||
log_time = pendulum.parse(log["time"])
|
||||
except Exception:
|
||||
return False
|
||||
if from_dt and log_time < from_dt:
|
||||
return False
|
||||
if to_dt and log_time > to_dt:
|
||||
return False
|
||||
return True
|
||||
# Add the console handler to the logger
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
matched_logs = []
|
||||
lines: list[str] = []
|
||||
if log_file and len(logger.handlers) < 2: # We assume a console logger to be the first logger
|
||||
# If a log file path is specified, create a rotating file handler
|
||||
|
||||
if tail:
|
||||
with log_path.open("rb") as f:
|
||||
f.seek(0, 2)
|
||||
end = f.tell()
|
||||
buffer = bytearray()
|
||||
pointer = end
|
||||
# Ensure the log directory exists
|
||||
log_dir = os.path.dirname(log_file)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
while pointer > 0 and len(lines) < limit * 5:
|
||||
pointer -= 1
|
||||
f.seek(pointer)
|
||||
byte = f.read(1)
|
||||
if byte == b"\n":
|
||||
if buffer:
|
||||
line = buffer[::-1].decode("utf-8", errors="ignore")
|
||||
lines.append(line)
|
||||
buffer.clear()
|
||||
else:
|
||||
buffer.append(byte[0])
|
||||
if buffer:
|
||||
line = buffer[::-1].decode("utf-8", errors="ignore")
|
||||
lines.append(line)
|
||||
lines = lines[::-1]
|
||||
else:
|
||||
with log_path.open("r", encoding="utf-8", newline=None) as f_txt:
|
||||
lines = f_txt.readlines()
|
||||
# Create a rotating file handler
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=max_bytes, backupCount=backup_count)
|
||||
if logging_level is not None:
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
log = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if matches_filters(log):
|
||||
matched_logs.append(log)
|
||||
if len(matched_logs) >= limit:
|
||||
break
|
||||
# Add the file handler to the logger
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return matched_logs
|
||||
return logger
|
||||
|
@@ -3,61 +3,43 @@
|
||||
Kept in an extra module to avoid cyclic dependencies on package import.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
from akkudoktoreos.core.logabc import logging_str_to_level
|
||||
|
||||
|
||||
class LoggingCommonSettings(SettingsBaseModel):
|
||||
"""Logging Configuration."""
|
||||
"""Common settings for logging."""
|
||||
|
||||
level: Optional[str] = Field(
|
||||
default=None,
|
||||
deprecated="This is deprecated. Use console_level and file_level instead.",
|
||||
logging_level_default: Optional[str] = Field(
|
||||
default=None, description="EOS default logging level."
|
||||
)
|
||||
|
||||
console_level: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Logging level when logging to console.",
|
||||
examples=LOGGING_LEVELS,
|
||||
)
|
||||
|
||||
file_level: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Logging level when logging to file.",
|
||||
examples=LOGGING_LEVELS,
|
||||
)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def file_path(self) -> Optional[Path]:
|
||||
"""Computed log file path based on data output path."""
|
||||
try:
|
||||
path = SettingsBaseModel.config.general.data_output_path / "eos.log"
|
||||
except:
|
||||
# Config may not be fully set up
|
||||
path = None
|
||||
return path
|
||||
|
||||
# Validators
|
||||
@field_validator("console_level", "file_level", mode="after")
|
||||
@field_validator("logging_level_default", mode="after")
|
||||
@classmethod
|
||||
def validate_level(cls, value: Optional[str]) -> Optional[str]:
|
||||
"""Validate logging level string."""
|
||||
def set_default_logging_level(cls, value: Optional[str]) -> Optional[str]:
|
||||
if isinstance(value, str) and value.upper() == "NONE":
|
||||
value = None
|
||||
if value is None and (env_level := os.getenv("EOS_LOGGING_LEVEL")) is not None:
|
||||
# Take default logging level from special environment variable
|
||||
value = env_level
|
||||
if value is None:
|
||||
# Nothing to set
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
level = value.upper()
|
||||
if level == "NONE":
|
||||
return None
|
||||
if level not in LOGGING_LEVELS:
|
||||
raise ValueError(f"Logging level {value} not supported")
|
||||
value = level
|
||||
else:
|
||||
raise TypeError(f"Invalid {type(value)} of logging level {value}")
|
||||
level = logging_str_to_level(value)
|
||||
logging.getLogger().setLevel(level)
|
||||
return value
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def logging_level_root(self) -> str:
|
||||
"""Root logger logging level."""
|
||||
level = logging.getLogger().getEffectiveLevel()
|
||||
level_name = logging.getLevelName(level)
|
||||
return level_name
|
||||
|
@@ -12,35 +12,19 @@ Key Features:
|
||||
pandas DataFrames and Series with datetime indexes.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
import weakref
|
||||
from copy import deepcopy
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Type,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
)
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pandas as pd
|
||||
import pendulum
|
||||
from loguru import logger
|
||||
from pandas.api.types import is_datetime64_any_dtype
|
||||
from pydantic import (
|
||||
AwareDatetime,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
PrivateAttr,
|
||||
RootModel,
|
||||
TypeAdapter,
|
||||
ValidationError,
|
||||
@@ -50,25 +34,6 @@ from pydantic import (
|
||||
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
# Global weakref dictionary to hold external state per model instance
|
||||
# Used as a workaround for PrivateAttr not working in e.g. Mixin Classes
|
||||
_model_private_state: "weakref.WeakKeyDictionary[Union[PydanticBaseModel, PydanticModelNestedValueMixin], Dict[str, Any]]" = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
def deep_update(source_dict: dict[str, Any], update_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
for key, value in source_dict.items():
|
||||
if isinstance(value, dict) and isinstance(update_dict.get(key), dict):
|
||||
update_dict[key] = deep_update(update_dict[key], value)
|
||||
else:
|
||||
update_dict[key] = value
|
||||
return update_dict
|
||||
|
||||
source_dict = source.model_dump(exclude_unset=True)
|
||||
merged_dict = deep_update(source_dict, deepcopy(update_dict))
|
||||
|
||||
return merged_dict
|
||||
|
||||
|
||||
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
|
||||
"""Custom type adapter for Pendulum DateTime fields."""
|
||||
@@ -101,538 +66,11 @@ class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
|
||||
return bool(re.match(iso8601_pattern, value))
|
||||
|
||||
|
||||
class PydanticModelNestedValueMixin:
|
||||
"""A mixin providing methods to get, set and track nested values within a Pydantic model.
|
||||
class PydanticBaseModel(BaseModel):
|
||||
"""Base model class with automatic serialization and deserialization of `pendulum.DateTime` fields.
|
||||
|
||||
The methods use a '/'-separated path to denote the nested values.
|
||||
Supports handling `Optional`, `List`, and `Dict` types, ensuring correct initialization of
|
||||
missing attributes.
|
||||
|
||||
|
||||
Example:
|
||||
class Address(PydanticBaseModel):
|
||||
city: str
|
||||
|
||||
class User(PydanticBaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
def on_city_change(old, new, path):
|
||||
print(f"{path}: {old} -> {new}")
|
||||
|
||||
user = User(name="Alice", address=Address(city="NY"))
|
||||
user.track_nested_value("address/city", on_city_change)
|
||||
user.set_nested_value("address/city", "LA") # triggers callback
|
||||
|
||||
"""
|
||||
|
||||
def track_nested_value(self, path: str, callback: Callable[[Any, str, Any, Any], None]) -> None:
|
||||
"""Register a callback for a specific path (or subtree).
|
||||
|
||||
Callback triggers if set path is equal or deeper.
|
||||
|
||||
Args:
|
||||
path (str): '/'-separated path to track.
|
||||
callback (callable): Function called as callback(model_instance, set_path, old_value, new_value).
|
||||
"""
|
||||
try:
|
||||
self._validate_path_structure(path)
|
||||
pass
|
||||
except:
|
||||
raise ValueError(f"Path '{path}' is invalid")
|
||||
path = path.strip("/")
|
||||
# Use private data workaround
|
||||
# Should be:
|
||||
# _nested_value_callbacks: dict[str, list[Callable[[str, Any, Any], None]]]
|
||||
# = PrivateAttr(default_factory=dict)
|
||||
nested_value_callbacks = get_private_attr(self, "nested_value_callbacks", dict())
|
||||
if path not in nested_value_callbacks:
|
||||
nested_value_callbacks[path] = []
|
||||
nested_value_callbacks[path].append(callback)
|
||||
set_private_attr(self, "nested_value_callbacks", nested_value_callbacks)
|
||||
|
||||
logger.debug("Nested value callbacks {}", nested_value_callbacks)
|
||||
|
||||
def _validate_path_structure(self, path: str) -> None:
|
||||
"""Validate that a '/'-separated path is structurally valid for this model.
|
||||
|
||||
Checks that each segment of the path corresponds to a field or index in the model's type structure,
|
||||
without requiring that all intermediate values are currently initialized. This method is intended
|
||||
to ensure that the path could be valid for nested access or assignment, according to the model's
|
||||
class definition.
|
||||
|
||||
Args:
|
||||
path (str): The '/'-separated attribute/index path to validate (e.g., "address/city" or "items/0/value").
|
||||
|
||||
Raises:
|
||||
ValueError: If any segment of the path does not correspond to a valid field in the model,
|
||||
or an invalid transition is made (such as an attribute on a non-model).
|
||||
|
||||
Example:
|
||||
class Address(PydanticBaseModel):
|
||||
city: str
|
||||
|
||||
class User(PydanticBaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
user = User(name="Alice", address=Address(city="NY"))
|
||||
user._validate_path_structure("address/city") # OK
|
||||
user._validate_path_structure("address/zipcode") # Raises ValueError
|
||||
"""
|
||||
path_elements = path.strip("/").split("/")
|
||||
# The model we are currently working on
|
||||
model: Any = self
|
||||
# The model we get the type information from. It is a pydantic BaseModel
|
||||
parent: BaseModel = model
|
||||
# The field that provides type information for the current key
|
||||
# Fields may have nested types that translates to a sequence of keys, not just one
|
||||
# - my_field: Optional[list[OtherModel]] -> e.g. "myfield/0" for index 0
|
||||
# parent_key = ["myfield",] ... ["myfield", "0"]
|
||||
# parent_key_types = [list, OtherModel]
|
||||
parent_key: list[str] = []
|
||||
parent_key_types: list = []
|
||||
|
||||
for i, key in enumerate(path_elements):
|
||||
is_final_key = i == len(path_elements) - 1
|
||||
# Add current key to parent key to enable nested type tracking
|
||||
parent_key.append(key)
|
||||
|
||||
# Get next value
|
||||
next_value = None
|
||||
if isinstance(model, BaseModel):
|
||||
# Track parent and key for possible assignment later
|
||||
parent = model
|
||||
parent_key = [
|
||||
key,
|
||||
]
|
||||
parent_key_types = self._get_key_types(model.__class__, key)
|
||||
|
||||
# If this is the final key, set the value
|
||||
if is_final_key:
|
||||
return
|
||||
|
||||
# Attempt to access the next attribute, handling None values
|
||||
next_value = getattr(model, key, None)
|
||||
|
||||
# Handle missing values (initialize dict/list/model if necessary)
|
||||
if next_value is None:
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
next_value = self._initialize_value(next_type)
|
||||
|
||||
elif isinstance(model, list):
|
||||
# Handle lists
|
||||
try:
|
||||
idx = int(key)
|
||||
except Exception as e:
|
||||
raise IndexError(
|
||||
f"Invalid list index '{key}' at '{path}': key = '{key}'; parent = '{parent}', parent_key = '{parent_key}'; model = '{model}'; {e}"
|
||||
)
|
||||
|
||||
# Get next type from parent key type information
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
|
||||
if len(model) > idx:
|
||||
next_value = model[idx]
|
||||
else:
|
||||
return
|
||||
|
||||
if is_final_key:
|
||||
return
|
||||
|
||||
elif isinstance(model, dict):
|
||||
# Handle dictionaries (auto-create missing keys)
|
||||
|
||||
# Get next type from parent key type information
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
|
||||
if is_final_key:
|
||||
return
|
||||
|
||||
if key not in model:
|
||||
return
|
||||
else:
|
||||
next_value = model[key]
|
||||
|
||||
else:
|
||||
raise KeyError(f"Key '{key}' not found in model.")
|
||||
|
||||
# Move deeper
|
||||
model = next_value
|
||||
|
||||
def get_nested_value(self, path: str) -> Any:
|
||||
"""Retrieve a nested value from the model using a '/'-separated path.
|
||||
|
||||
Supports accessing nested attributes and list indices.
|
||||
|
||||
Args:
|
||||
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
|
||||
|
||||
Returns:
|
||||
Any: The retrieved value.
|
||||
|
||||
Raises:
|
||||
KeyError: If a key is not found in the model.
|
||||
IndexError: If a list index is out of bounds or invalid.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class Address(PydanticBaseModel):
|
||||
city: str
|
||||
|
||||
class User(PydanticBaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
user = User(name="Alice", address=Address(city="New York"))
|
||||
city = user.get_nested_value("address/city")
|
||||
print(city) # Output: "New York"
|
||||
```
|
||||
"""
|
||||
path_elements = path.strip("/").split("/")
|
||||
model: Any = self
|
||||
|
||||
for key in path_elements:
|
||||
if isinstance(model, list):
|
||||
try:
|
||||
model = model[int(key)]
|
||||
except (ValueError, IndexError) as e:
|
||||
raise IndexError(f"Invalid list index at '{path}': {key}; {e}")
|
||||
elif isinstance(model, dict):
|
||||
try:
|
||||
model = model[key]
|
||||
except Exception as e:
|
||||
raise KeyError(f"Invalid dict key at '{path}': {key}; {e}")
|
||||
elif isinstance(model, BaseModel):
|
||||
model = getattr(model, key)
|
||||
else:
|
||||
raise KeyError(f"Key '{key}' not found in model.")
|
||||
|
||||
return model
|
||||
|
||||
def set_nested_value(self, path: str, value: Any) -> None:
|
||||
"""Set a nested value in the model using a '/'-separated path.
|
||||
|
||||
Supports modifying nested attributes and list indices while preserving Pydantic validation.
|
||||
Automatically initializes missing `Optional`, `Union`, `dict`, and `list` fields if necessary.
|
||||
If a missing field cannot be initialized, raises an exception.
|
||||
|
||||
Triggers the callbacks registered by track_nested_value().
|
||||
|
||||
Args:
|
||||
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
|
||||
value (Any): The new value to set.
|
||||
|
||||
Raises:
|
||||
KeyError: If a key is not found in the model.
|
||||
IndexError: If a list index is out of bounds or invalid.
|
||||
ValueError: If a validation error occurs.
|
||||
TypeError: If a missing field cannot be initialized.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class Address(PydanticBaseModel):
|
||||
city: Optional[str]
|
||||
|
||||
class User(PydanticBaseModel):
|
||||
name: str
|
||||
address: Optional[Address]
|
||||
settings: Optional[Dict[str, Any]]
|
||||
|
||||
user = User(name="Alice", address=None, settings=None)
|
||||
user.set_nested_value("address/city", "Los Angeles")
|
||||
user.set_nested_value("settings/theme", "dark")
|
||||
|
||||
print(user.address.city) # Output: "Los Angeles"
|
||||
print(user.settings) # Output: {'theme': 'dark'}
|
||||
```
|
||||
"""
|
||||
path = path.strip("/")
|
||||
# Store old value (if possible)
|
||||
try:
|
||||
old_value = self.get_nested_value(path)
|
||||
except Exception as e:
|
||||
# We can not get the old value
|
||||
# raise ValueError(f"Can not get old (current) value of '{path}': {e}") from e
|
||||
old_value = None
|
||||
|
||||
# Proceed with core logic
|
||||
self._set_nested_value(path, value)
|
||||
|
||||
# Trigger all callbacks whose path is a prefix of set path
|
||||
triggered = set()
|
||||
nested_value_callbacks = get_private_attr(self, "nested_value_callbacks", dict())
|
||||
for cb_path, callbacks in nested_value_callbacks.items():
|
||||
# Match: cb_path == path, or cb_path is a prefix (parent) of path
|
||||
pass
|
||||
if path == cb_path or path.startswith(cb_path + "/"):
|
||||
for cb in callbacks:
|
||||
# Prevent duplicate calls
|
||||
if (cb_path, id(cb)) not in triggered:
|
||||
cb(self, path, old_value, value)
|
||||
triggered.add((cb_path, id(cb)))
|
||||
|
||||
def _set_nested_value(self, path: str, value: Any) -> None:
|
||||
"""Set a nested value core logic.
|
||||
|
||||
Args:
|
||||
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
|
||||
value (Any): The new value to set.
|
||||
|
||||
Raises:
|
||||
KeyError: If a key is not found in the model.
|
||||
IndexError: If a list index is out of bounds or invalid.
|
||||
ValueError: If a validation error occurs.
|
||||
TypeError: If a missing field cannot be initialized.
|
||||
"""
|
||||
path_elements = path.strip("/").split("/")
|
||||
# The model we are currently working on
|
||||
model: Any = self
|
||||
# The model we get the type information from. It is a pydantic BaseModel
|
||||
parent: BaseModel = model
|
||||
# The field that provides type information for the current key
|
||||
# Fields may have nested types that translates to a sequence of keys, not just one
|
||||
# - my_field: Optional[list[OtherModel]] -> e.g. "myfield/0" for index 0
|
||||
# parent_key = ["myfield",] ... ["myfield", "0"]
|
||||
# parent_key_types = [list, OtherModel]
|
||||
parent_key: list[str] = []
|
||||
parent_key_types: list = []
|
||||
|
||||
for i, key in enumerate(path_elements):
|
||||
is_final_key = i == len(path_elements) - 1
|
||||
# Add current key to parent key to enable nested type tracking
|
||||
parent_key.append(key)
|
||||
|
||||
# Get next value
|
||||
next_value = None
|
||||
if isinstance(model, BaseModel):
|
||||
# Track parent and key for possible assignment later
|
||||
parent = model
|
||||
parent_key = [
|
||||
key,
|
||||
]
|
||||
parent_key_types = self._get_key_types(model.__class__, key)
|
||||
|
||||
# If this is the final key, set the value
|
||||
if is_final_key:
|
||||
try:
|
||||
model.__pydantic_validator__.validate_assignment(model, key, value)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"Error updating model: {e}") from e
|
||||
return
|
||||
|
||||
# Attempt to access the next attribute, handling None values
|
||||
next_value = getattr(model, key, None)
|
||||
|
||||
# Handle missing values (initialize dict/list/model if necessary)
|
||||
if next_value is None:
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
next_value = self._initialize_value(next_type)
|
||||
if next_value is None:
|
||||
raise TypeError(
|
||||
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
|
||||
)
|
||||
setattr(parent, key, next_value)
|
||||
# pydantic may copy on validation assignment - reread to get the copied model
|
||||
next_value = getattr(model, key, None)
|
||||
|
||||
elif isinstance(model, list):
|
||||
# Handle lists (ensure index exists and modify safely)
|
||||
try:
|
||||
idx = int(key)
|
||||
except Exception as e:
|
||||
raise IndexError(
|
||||
f"Invalid list index '{key}' at '{path}': key = '{key}'; parent = '{parent}', parent_key = '{parent_key}'; model = '{model}'; {e}"
|
||||
)
|
||||
|
||||
# Get next type from parent key type information
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
|
||||
if len(model) > idx:
|
||||
next_value = model[idx]
|
||||
else:
|
||||
# Extend the list with default values if index is out of range
|
||||
while len(model) <= idx:
|
||||
next_value = self._initialize_value(next_type)
|
||||
if next_value is None:
|
||||
raise TypeError(
|
||||
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
|
||||
)
|
||||
model.append(next_value)
|
||||
|
||||
if is_final_key:
|
||||
if (
|
||||
(isinstance(next_type, type) and not isinstance(value, next_type))
|
||||
or (next_type is dict and not isinstance(value, dict))
|
||||
or (next_type is list and not isinstance(value, list))
|
||||
):
|
||||
raise TypeError(
|
||||
f"Expected type {next_type} for key '{key}' in path '{path}', but got {type(value)}: {value}"
|
||||
)
|
||||
model[idx] = value
|
||||
return
|
||||
|
||||
elif isinstance(model, dict):
|
||||
# Handle dictionaries (auto-create missing keys)
|
||||
|
||||
# Get next type from parent key type information
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
|
||||
if is_final_key:
|
||||
if (
|
||||
(isinstance(next_type, type) and not isinstance(value, next_type))
|
||||
or (next_type is dict and not isinstance(value, dict))
|
||||
or (next_type is list and not isinstance(value, list))
|
||||
):
|
||||
raise TypeError(
|
||||
f"Expected type {next_type} for key '{key}' in path '{path}', but got {type(value)}: {value}"
|
||||
)
|
||||
model[key] = value
|
||||
return
|
||||
|
||||
if key not in model:
|
||||
next_value = self._initialize_value(next_type)
|
||||
if next_value is None:
|
||||
raise TypeError(
|
||||
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
|
||||
)
|
||||
model[key] = next_value
|
||||
else:
|
||||
next_value = model[key]
|
||||
|
||||
else:
|
||||
raise KeyError(f"Key '{key}' not found in model.")
|
||||
|
||||
# Move deeper
|
||||
model = next_value
|
||||
|
||||
@staticmethod
|
||||
def _get_key_types(model: Type[BaseModel], key: str) -> List[Union[Type[Any], list, dict]]:
|
||||
"""Returns a list of nested types for a given Pydantic model key.
|
||||
|
||||
- Skips `Optional` and `Union`, using only the first non-None type.
|
||||
- Skips dictionary keys and only adds value types.
|
||||
- Keeps `list` and `dict` as origins.
|
||||
|
||||
Args:
|
||||
model (Type[BaseModel]): The Pydantic model class to inspect.
|
||||
key (str): The attribute name in the model.
|
||||
|
||||
Returns:
|
||||
List[Union[Type[Any], list, dict]]: A list of extracted types, preserving `list` and `dict` origins.
|
||||
|
||||
Raises:
|
||||
TypeError: If the key does not exist or lacks a valid type annotation.
|
||||
"""
|
||||
if not inspect.isclass(model):
|
||||
raise TypeError(f"Model '{model}' is not of class type.")
|
||||
|
||||
if key not in model.model_fields:
|
||||
raise TypeError(f"Field '{key}' does not exist in model '{model.__name__}'.")
|
||||
|
||||
field_annotation = model.model_fields[key].annotation
|
||||
if not field_annotation:
|
||||
raise TypeError(
|
||||
f"Missing type annotation for field '{key}' in model '{model.__name__}'."
|
||||
)
|
||||
|
||||
nested_types: list[Union[Type[Any], list, dict]] = []
|
||||
queue: list[Any] = [field_annotation]
|
||||
|
||||
while queue:
|
||||
annotation = queue.pop(0)
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
|
||||
# Handle Union (Optional[X] is treated as Union[X, None])
|
||||
if origin is Union:
|
||||
queue.extend(arg for arg in args if arg is not type(None))
|
||||
continue
|
||||
|
||||
# Handle lists and dictionaries
|
||||
if origin is list:
|
||||
nested_types.append(list)
|
||||
if args:
|
||||
queue.append(args[0]) # Extract value type for list[T]
|
||||
continue
|
||||
|
||||
if origin is dict:
|
||||
nested_types.append(dict)
|
||||
if len(args) == 2:
|
||||
queue.append(args[1]) # Extract only the value type for dict[K, V]
|
||||
continue
|
||||
|
||||
# If it's a BaseModel, add it to the list
|
||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||
nested_types.append(annotation)
|
||||
continue
|
||||
|
||||
# Otherwise, it's a standard type (e.g., str, int, bool, float, etc.)
|
||||
nested_types.append(annotation)
|
||||
|
||||
return nested_types
|
||||
|
||||
@staticmethod
|
||||
def _initialize_value(type_hint: Type[Any] | None | list[Any] | dict[Any, Any]) -> Any:
|
||||
"""Initialize a missing value based on the provided type hint.
|
||||
|
||||
Args:
|
||||
type_hint (Type[Any] | None | list[Any] | dict[Any, Any]): The type hint that determines
|
||||
how the missing value should be initialized.
|
||||
|
||||
Returns:
|
||||
Any: An instance of the expected type (e.g., list, dict, or Pydantic model), or `None`
|
||||
if initialization is not possible.
|
||||
|
||||
Raises:
|
||||
TypeError: If instantiation fails.
|
||||
|
||||
Example:
|
||||
- For `list[str]`, returns `[]`
|
||||
- For `dict[str, Any]`, returns `{}`
|
||||
- For `Address` (a Pydantic model), returns a new `Address()` instance.
|
||||
"""
|
||||
if type_hint is None:
|
||||
return None
|
||||
|
||||
# Handle direct instances of list or dict
|
||||
if isinstance(type_hint, list):
|
||||
return []
|
||||
if isinstance(type_hint, dict):
|
||||
return {}
|
||||
|
||||
origin = get_origin(type_hint)
|
||||
|
||||
# Handle generic list and dictionary
|
||||
if origin is list:
|
||||
return []
|
||||
if origin is dict:
|
||||
return {}
|
||||
|
||||
# Handle Pydantic models
|
||||
if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
|
||||
try:
|
||||
return type_hint.model_construct()
|
||||
except Exception as e:
|
||||
raise TypeError(f"Failed to initialize model '{type_hint.__name__}': {e}")
|
||||
|
||||
# Handle standard built-in types (int, float, str, bool, etc.)
|
||||
if isinstance(type_hint, type):
|
||||
try:
|
||||
return type_hint()
|
||||
except Exception as e:
|
||||
raise TypeError(f"Failed to initialize instance of '{type_hint.__name__}': {e}")
|
||||
|
||||
raise TypeError(f"Unsupported type hint '{type_hint}' for initialization.")
|
||||
|
||||
|
||||
class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
"""Base model with pendulum datetime support, nested value utilities, and stable hashing.
|
||||
|
||||
This class provides:
|
||||
- ISO 8601 serialization/deserialization of `pendulum.DateTime` fields.
|
||||
- Nested attribute access and mutation via `PydanticModelNestedValueMixin`.
|
||||
- A consistent hash using a UUID for use in sets and as dictionary keys
|
||||
This model serializes pendulum.DateTime objects to ISO 8601 strings and
|
||||
deserializes ISO 8601 strings to pendulum.DateTime objects.
|
||||
"""
|
||||
|
||||
# Enable custom serialization globally in config
|
||||
@@ -642,17 +80,6 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
_uuid: str = PrivateAttr(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""str: A private UUID string generated on instantiation, used for hashing."""
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Returns a stable hash based on the instance's UUID.
|
||||
|
||||
Returns:
|
||||
int: Hash value derived from the model's UUID.
|
||||
"""
|
||||
return hash(self._uuid)
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
def validate_and_convert_pendulum(cls, value: Any, info: ValidationInfo) -> Any:
|
||||
"""Validator to convert fields of type `pendulum.DateTime`.
|
||||
@@ -681,21 +108,14 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
if expected_type is pendulum.DateTime or expected_type is AwareDatetime:
|
||||
try:
|
||||
value = to_datetime(value)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Cannot convert {value!r} to datetime: {e}")
|
||||
except:
|
||||
pass
|
||||
return value
|
||||
|
||||
# Override Pydantic’s serialization for all DateTime fields
|
||||
def model_dump(
|
||||
self, *args: Any, include_computed_fields: bool = True, **kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
def model_dump(self, *args: Any, **kwargs: Any) -> dict:
|
||||
"""Custom dump method to handle serialization for DateTime fields."""
|
||||
result = super().model_dump(*args, **kwargs)
|
||||
|
||||
if not include_computed_fields:
|
||||
for computed_field_name in self.model_computed_fields:
|
||||
result.pop(computed_field_name, None)
|
||||
|
||||
for key, value in result.items():
|
||||
if isinstance(value, pendulum.DateTime):
|
||||
result[key] = PydanticTypeAdapterDateTime.serialize(value)
|
||||
@@ -750,10 +170,6 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
"""
|
||||
return cls.model_validate(data)
|
||||
|
||||
def model_dump_json(self, *args: Any, indent: Optional[int] = None, **kwargs: Any) -> str:
|
||||
data = self.model_dump(*args, **kwargs)
|
||||
return json.dumps(data, indent=indent, default=str)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convert the PydanticBaseModel instance to a JSON string.
|
||||
|
||||
@@ -930,10 +346,6 @@ class PydanticDateTimeDataFrame(PydanticBaseModel):
|
||||
index = pd.Index([to_datetime(dt, in_timezone=self.tz) for dt in df.index])
|
||||
df.index = index
|
||||
|
||||
# Check if 'date_time' column exists, if not, create it
|
||||
if "date_time" not in df.columns:
|
||||
df["date_time"] = df.index
|
||||
|
||||
dtype_mapping = {
|
||||
"int": int,
|
||||
"float": float,
|
||||
@@ -1070,27 +482,3 @@ class PydanticDateTimeSeries(PydanticBaseModel):
|
||||
|
||||
class ParametersBaseModel(PydanticBaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def set_private_attr(
|
||||
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str, value: Any
|
||||
) -> None:
|
||||
"""Set a private attribute for a model instance (not stored in model itself)."""
|
||||
if model not in _model_private_state:
|
||||
_model_private_state[model] = {}
|
||||
_model_private_state[model][key] = value
|
||||
|
||||
|
||||
def get_private_attr(
|
||||
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str, default: Any = None
|
||||
) -> Any:
|
||||
"""Get a private attribute or return default."""
|
||||
return _model_private_state.get(model, {}).get(key, default)
|
||||
|
||||
|
||||
def del_private_attr(
|
||||
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str
|
||||
) -> None:
|
||||
"""Delete a private attribute."""
|
||||
if model in _model_private_state and key in _model_private_state[model]:
|
||||
del _model_private_state[model][key]
|
||||
|
@@ -1,2 +1,113 @@
|
||||
{
|
||||
"config_file_path": null,
|
||||
"config_folder_path": null,
|
||||
"data_cache_path": null,
|
||||
"data_cache_subpath": null,
|
||||
"data_folder_path": null,
|
||||
"data_output_path": null,
|
||||
"data_output_subpath": null,
|
||||
"elecprice_charges_kwh": 0.21,
|
||||
"elecprice_provider": null,
|
||||
"elecpriceimport_file_path": null,
|
||||
"latitude": 52.5,
|
||||
"load_import_file_path": null,
|
||||
"load_name": null,
|
||||
"load_provider": null,
|
||||
"loadakkudoktor_year_energy": null,
|
||||
"logging_level": "INFO",
|
||||
"longitude": 13.4,
|
||||
"optimization_ev_available_charge_rates_percent": null,
|
||||
"optimization_hours": 48,
|
||||
"optimization_penalty": null,
|
||||
"prediction_historic_hours": 48,
|
||||
"prediction_hours": 48,
|
||||
"pvforecast0_albedo": null,
|
||||
"pvforecast0_inverter_model": null,
|
||||
"pvforecast0_inverter_paco": null,
|
||||
"pvforecast0_loss": null,
|
||||
"pvforecast0_module_model": null,
|
||||
"pvforecast0_modules_per_string": null,
|
||||
"pvforecast0_mountingplace": "free",
|
||||
"pvforecast0_optimal_surface_tilt": false,
|
||||
"pvforecast0_optimalangles": false,
|
||||
"pvforecast0_peakpower": null,
|
||||
"pvforecast0_pvtechchoice": "crystSi",
|
||||
"pvforecast0_strings_per_inverter": null,
|
||||
"pvforecast0_surface_azimuth": 180,
|
||||
"pvforecast0_surface_tilt": 0,
|
||||
"pvforecast0_trackingtype": 0,
|
||||
"pvforecast0_userhorizon": null,
|
||||
"pvforecast1_albedo": null,
|
||||
"pvforecast1_inverter_model": null,
|
||||
"pvforecast1_inverter_paco": null,
|
||||
"pvforecast1_loss": 0,
|
||||
"pvforecast1_module_model": null,
|
||||
"pvforecast1_modules_per_string": null,
|
||||
"pvforecast1_mountingplace": "free",
|
||||
"pvforecast1_optimal_surface_tilt": false,
|
||||
"pvforecast1_optimalangles": false,
|
||||
"pvforecast1_peakpower": null,
|
||||
"pvforecast1_pvtechchoice": "crystSi",
|
||||
"pvforecast1_strings_per_inverter": null,
|
||||
"pvforecast1_surface_azimuth": 180,
|
||||
"pvforecast1_surface_tilt": 0,
|
||||
"pvforecast1_trackingtype": 0,
|
||||
"pvforecast1_userhorizon": null,
|
||||
"pvforecast2_albedo": null,
|
||||
"pvforecast2_inverter_model": null,
|
||||
"pvforecast2_inverter_paco": null,
|
||||
"pvforecast2_loss": 0,
|
||||
"pvforecast2_module_model": null,
|
||||
"pvforecast2_modules_per_string": null,
|
||||
"pvforecast2_mountingplace": "free",
|
||||
"pvforecast2_optimal_surface_tilt": false,
|
||||
"pvforecast2_optimalangles": false,
|
||||
"pvforecast2_peakpower": null,
|
||||
"pvforecast2_pvtechchoice": "crystSi",
|
||||
"pvforecast2_strings_per_inverter": null,
|
||||
"pvforecast2_surface_azimuth": 180,
|
||||
"pvforecast2_surface_tilt": 0,
|
||||
"pvforecast2_trackingtype": 0,
|
||||
"pvforecast2_userhorizon": null,
|
||||
"pvforecast3_albedo": null,
|
||||
"pvforecast3_inverter_model": null,
|
||||
"pvforecast3_inverter_paco": null,
|
||||
"pvforecast3_loss": 0,
|
||||
"pvforecast3_module_model": null,
|
||||
"pvforecast3_modules_per_string": null,
|
||||
"pvforecast3_mountingplace": "free",
|
||||
"pvforecast3_optimal_surface_tilt": false,
|
||||
"pvforecast3_optimalangles": false,
|
||||
"pvforecast3_peakpower": null,
|
||||
"pvforecast3_pvtechchoice": "crystSi",
|
||||
"pvforecast3_strings_per_inverter": null,
|
||||
"pvforecast3_surface_azimuth": 180,
|
||||
"pvforecast3_surface_tilt": 0,
|
||||
"pvforecast3_trackingtype": 0,
|
||||
"pvforecast3_userhorizon": null,
|
||||
"pvforecast4_albedo": null,
|
||||
"pvforecast4_inverter_model": null,
|
||||
"pvforecast4_inverter_paco": null,
|
||||
"pvforecast4_loss": 0,
|
||||
"pvforecast4_module_model": null,
|
||||
"pvforecast4_modules_per_string": null,
|
||||
"pvforecast4_mountingplace": "free",
|
||||
"pvforecast4_optimal_surface_tilt": false,
|
||||
"pvforecast4_optimalangles": false,
|
||||
"pvforecast4_peakpower": null,
|
||||
"pvforecast4_pvtechchoice": "crystSi",
|
||||
"pvforecast4_strings_per_inverter": null,
|
||||
"pvforecast4_surface_azimuth": 180,
|
||||
"pvforecast4_surface_tilt": 0,
|
||||
"pvforecast4_trackingtype": 0,
|
||||
"pvforecast4_userhorizon": null,
|
||||
"pvforecast_provider": null,
|
||||
"pvforecastimport_file_path": null,
|
||||
"server_eos_startup_eosdash": true,
|
||||
"server_eos_host": "0.0.0.0",
|
||||
"server_eos_port": 8503,
|
||||
"server_eosdash_host": "0.0.0.0",
|
||||
"server_eosdash_port": 8504,
|
||||
"weather_provider": null,
|
||||
"weatherimport_file_path": null
|
||||
}
|
||||
|
@@ -1,15 +1,15 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from akkudoktoreos.devices.devicesabc import (
|
||||
DeviceBase,
|
||||
DeviceOptimizeResult,
|
||||
DeviceParameters,
|
||||
)
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def max_charging_power_field(description: Optional[str] = None) -> float:
|
||||
if description is None:
|
||||
@@ -22,26 +22,14 @@ def max_charging_power_field(description: Optional[str] = None) -> float:
|
||||
|
||||
|
||||
def initial_soc_percentage_field(description: str) -> int:
|
||||
return Field(default=0, ge=0, le=100, description=description, examples=[42])
|
||||
return Field(default=0, ge=0, le=100, description=description)
|
||||
|
||||
|
||||
def discharging_efficiency_field(default_value: float) -> float:
|
||||
return Field(
|
||||
default=default_value,
|
||||
gt=0,
|
||||
le=1,
|
||||
description="A float representing the discharge efficiency of the battery.",
|
||||
)
|
||||
class BaseBatteryParameters(ParametersBaseModel):
|
||||
"""Base class for battery parameters with fields for capacity, efficiency, and state of charge."""
|
||||
|
||||
|
||||
class BaseBatteryParameters(DeviceParameters):
|
||||
"""Battery Device Simulation Configuration."""
|
||||
|
||||
device_id: str = Field(description="ID of battery", examples=["battery1"])
|
||||
capacity_wh: int = Field(
|
||||
gt=0,
|
||||
description="An integer representing the capacity of the battery in watt-hours.",
|
||||
examples=[8000],
|
||||
gt=0, description="An integer representing the capacity of the battery in watt-hours."
|
||||
)
|
||||
charging_efficiency: float = Field(
|
||||
default=0.88,
|
||||
@@ -49,7 +37,12 @@ class BaseBatteryParameters(DeviceParameters):
|
||||
le=1,
|
||||
description="A float representing the charging efficiency of the battery.",
|
||||
)
|
||||
discharging_efficiency: float = discharging_efficiency_field(0.88)
|
||||
discharging_efficiency: float = Field(
|
||||
default=0.88,
|
||||
gt=0,
|
||||
le=1,
|
||||
description="A float representing the discharge efficiency of the battery.",
|
||||
)
|
||||
max_charge_power_w: Optional[float] = max_charging_power_field()
|
||||
initial_soc_percentage: int = initial_soc_percentage_field(
|
||||
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
|
||||
@@ -59,7 +52,6 @@ class BaseBatteryParameters(DeviceParameters):
|
||||
ge=0,
|
||||
le=100,
|
||||
description="An integer representing the minimum state of charge (SOC) of the battery in percentage.",
|
||||
examples=[10],
|
||||
)
|
||||
max_soc_percentage: int = Field(
|
||||
default=100,
|
||||
@@ -74,19 +66,17 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
|
||||
|
||||
|
||||
class ElectricVehicleParameters(BaseBatteryParameters):
|
||||
"""Battery Electric Vehicle Device Simulation Configuration."""
|
||||
"""Parameters specific to an electric vehicle (EV)."""
|
||||
|
||||
device_id: str = Field(description="ID of electric vehicle", examples=["ev1"])
|
||||
discharging_efficiency: float = discharging_efficiency_field(1.0)
|
||||
discharging_efficiency: float = 1.0
|
||||
initial_soc_percentage: int = initial_soc_percentage_field(
|
||||
"An integer representing the current state of charge (SOC) of the battery in percentage."
|
||||
)
|
||||
|
||||
|
||||
class ElectricVehicleResult(DeviceOptimizeResult):
|
||||
class ElectricVehicleResult(BaseModel):
|
||||
"""Result class containing information related to the electric vehicle's charging and discharging behavior."""
|
||||
|
||||
device_id: str = Field(description="ID of electric vehicle", examples=["ev1"])
|
||||
charge_array: list[float] = Field(
|
||||
description="Hourly charging status (0 for no charging, 1 for charging)."
|
||||
)
|
||||
@@ -94,6 +84,7 @@ class ElectricVehicleResult(DeviceOptimizeResult):
|
||||
description="Hourly discharging status (0 for no discharging, 1 for discharging)."
|
||||
)
|
||||
discharging_efficiency: float = Field(description="The discharge efficiency as a float..")
|
||||
hours: int = Field(description="Number of hours in the simulation.")
|
||||
capacity_wh: int = Field(description="Capacity of the EV’s battery in watt-hours.")
|
||||
charging_efficiency: float = Field(description="Charging efficiency as a float..")
|
||||
max_charge_power_w: int = Field(description="Maximum charging power in watts.")
|
||||
@@ -112,19 +103,67 @@ class ElectricVehicleResult(DeviceOptimizeResult):
|
||||
class Battery(DeviceBase):
|
||||
"""Represents a battery device with methods to simulate energy charging and discharging."""
|
||||
|
||||
def __init__(self, parameters: Optional[BaseBatteryParameters] = None):
|
||||
self.parameters: Optional[BaseBatteryParameters] = None
|
||||
super().__init__(parameters)
|
||||
def __init__(
|
||||
self,
|
||||
parameters: Optional[BaseBatteryParameters] = None,
|
||||
hours: Optional[int] = 24,
|
||||
provider_id: Optional[str] = None,
|
||||
):
|
||||
# Initialize configuration and parameters
|
||||
self.provider_id = provider_id
|
||||
self.prefix = "<invalid>"
|
||||
if self.provider_id == "GenericBattery":
|
||||
self.prefix = "battery"
|
||||
elif self.provider_id == "GenericBEV":
|
||||
self.prefix = "bev"
|
||||
|
||||
def _setup(self) -> None:
|
||||
self.parameters = parameters
|
||||
if hours is None:
|
||||
self.hours = self.total_hours # TODO where does that come from?
|
||||
else:
|
||||
self.hours = hours
|
||||
|
||||
self.initialised = False
|
||||
|
||||
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
|
||||
if self.parameters is not None:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Sets up the battery parameters based on configuration or provided parameters."""
|
||||
if self.parameters is None:
|
||||
raise ValueError(f"Parameters not set: {self.parameters}")
|
||||
if self.initialised:
|
||||
return
|
||||
|
||||
if self.provider_id:
|
||||
# Setup from configuration
|
||||
self.capacity_wh = getattr(self.config, f"{self.prefix}_capacity")
|
||||
self.initial_soc_percentage = getattr(self.config, f"{self.prefix}_initial_soc")
|
||||
self.hours = self.total_hours # TODO where does that come from?
|
||||
self.charging_efficiency = getattr(self.config, f"{self.prefix}_charging_efficiency")
|
||||
self.discharging_efficiency = getattr(
|
||||
self.config, f"{self.prefix}_discharging_efficiency"
|
||||
)
|
||||
self.max_charge_power_w = getattr(self.config, f"{self.prefix}_max_charging_power")
|
||||
|
||||
if self.provider_id == "GenericBattery":
|
||||
self.min_soc_percentage = getattr(
|
||||
self.config,
|
||||
f"{self.prefix}_soc_min",
|
||||
)
|
||||
else:
|
||||
self.min_soc_percentage = 0
|
||||
|
||||
self.max_soc_percentage = getattr(
|
||||
self.config,
|
||||
f"{self.prefix}_soc_max",
|
||||
)
|
||||
elif self.parameters:
|
||||
# Setup from parameters
|
||||
self.capacity_wh = self.parameters.capacity_wh
|
||||
self.initial_soc_percentage = self.parameters.initial_soc_percentage
|
||||
self.charging_efficiency = self.parameters.charging_efficiency
|
||||
self.discharging_efficiency = self.parameters.discharging_efficiency
|
||||
|
||||
self.max_charge_power_w = self.parameters.max_charge_power_w
|
||||
# Only assign for storage battery
|
||||
self.min_soc_percentage = (
|
||||
self.parameters.min_soc_percentage
|
||||
@@ -132,11 +171,13 @@ class Battery(DeviceBase):
|
||||
else 0
|
||||
)
|
||||
self.max_soc_percentage = self.parameters.max_soc_percentage
|
||||
else:
|
||||
error_msg = "Parameters and provider ID are missing. Cannot instantiate."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Initialize state of charge
|
||||
if self.parameters.max_charge_power_w is not None:
|
||||
self.max_charge_power_w = self.parameters.max_charge_power_w
|
||||
else:
|
||||
if self.max_charge_power_w is None:
|
||||
self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
|
||||
self.discharge_array = np.full(self.hours, 1)
|
||||
self.charge_array = np.full(self.hours, 1)
|
||||
@@ -144,10 +185,11 @@ class Battery(DeviceBase):
|
||||
self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh
|
||||
self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh
|
||||
|
||||
self.initialised = True
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Converts the object to a dictionary representation."""
|
||||
return {
|
||||
"device_id": self.device_id,
|
||||
"capacity_wh": self.capacity_wh,
|
||||
"initial_soc_percentage": self.initial_soc_percentage,
|
||||
"soc_wh": self.soc_wh,
|
||||
|
@@ -1,49 +1,313 @@
|
||||
from typing import Optional
|
||||
from typing import Any, ClassVar, Dict, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
from numpydantic import NDArray, Shape
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.devices.battery import Battery
|
||||
from akkudoktoreos.devices.devicesabc import DevicesBase
|
||||
from akkudoktoreos.devices.generic import HomeAppliance
|
||||
from akkudoktoreos.devices.inverter import Inverter
|
||||
from akkudoktoreos.devices.settings import DevicesCommonSettings
|
||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DevicesCommonSettings(SettingsBaseModel):
|
||||
"""Base configuration for devices simulation settings."""
|
||||
|
||||
# Battery
|
||||
# -------
|
||||
battery_provider: Optional[str] = Field(
|
||||
default=None, description="Id of Battery simulation provider."
|
||||
)
|
||||
battery_capacity: Optional[int] = Field(default=None, description="Battery capacity [Wh].")
|
||||
battery_initial_soc: Optional[int] = Field(
|
||||
default=None, description="Battery initial state of charge [%]."
|
||||
)
|
||||
battery_soc_min: Optional[int] = Field(
|
||||
default=None, description="Battery minimum state of charge [%]."
|
||||
)
|
||||
battery_soc_max: Optional[int] = Field(
|
||||
default=None, description="Battery maximum state of charge [%]."
|
||||
)
|
||||
battery_charging_efficiency: Optional[float] = Field(
|
||||
default=None, description="Battery charging efficiency [%]."
|
||||
)
|
||||
battery_discharging_efficiency: Optional[float] = Field(
|
||||
default=None, description="Battery discharging efficiency [%]."
|
||||
)
|
||||
battery_max_charging_power: Optional[int] = Field(
|
||||
default=None, description="Battery maximum charge power [W]."
|
||||
)
|
||||
|
||||
# Battery Electric Vehicle
|
||||
# ------------------------
|
||||
bev_provider: Optional[str] = Field(
|
||||
default=None, description="Id of Battery Electric Vehicle simulation provider."
|
||||
)
|
||||
bev_capacity: Optional[int] = Field(
|
||||
default=None, description="Battery Electric Vehicle capacity [Wh]."
|
||||
)
|
||||
bev_initial_soc: Optional[int] = Field(
|
||||
default=None, description="Battery Electric Vehicle initial state of charge [%]."
|
||||
)
|
||||
bev_soc_max: Optional[int] = Field(
|
||||
default=None, description="Battery Electric Vehicle maximum state of charge [%]."
|
||||
)
|
||||
bev_charging_efficiency: Optional[float] = Field(
|
||||
default=None, description="Battery Electric Vehicle charging efficiency [%]."
|
||||
)
|
||||
bev_discharging_efficiency: Optional[float] = Field(
|
||||
default=None, description="Battery Electric Vehicle discharging efficiency [%]."
|
||||
)
|
||||
bev_max_charging_power: Optional[int] = Field(
|
||||
default=None, description="Battery Electric Vehicle maximum charge power [W]."
|
||||
)
|
||||
|
||||
# Home Appliance - Dish Washer
|
||||
# ----------------------------
|
||||
dishwasher_provider: Optional[str] = Field(
|
||||
default=None, description="Id of Dish Washer simulation provider."
|
||||
)
|
||||
dishwasher_consumption: Optional[int] = Field(
|
||||
default=None, description="Dish Washer energy consumption [Wh]."
|
||||
)
|
||||
dishwasher_duration: Optional[int] = Field(
|
||||
default=None, description="Dish Washer usage duration [h]."
|
||||
)
|
||||
|
||||
# PV Inverter
|
||||
# -----------
|
||||
inverter_provider: Optional[str] = Field(
|
||||
default=None, description="Id of PV Inverter simulation provider."
|
||||
)
|
||||
inverter_power_max: Optional[float] = Field(
|
||||
default=None, description="Inverter maximum power [W]."
|
||||
)
|
||||
|
||||
|
||||
class Devices(SingletonMixin, DevicesBase):
|
||||
def __init__(self, settings: Optional[DevicesCommonSettings] = None):
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__()
|
||||
if settings is None:
|
||||
settings = self.config.devices
|
||||
if settings is None:
|
||||
return
|
||||
# Results of the devices simulation and
|
||||
# insights into various parameters over the entire forecast period.
|
||||
# -----------------------------------------------------------------
|
||||
last_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The load in watt-hours per hour."
|
||||
)
|
||||
eauto_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The state of charge of the EV for each hour."
|
||||
)
|
||||
einnahmen_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None,
|
||||
description="The revenue from grid feed-in or other sources in euros per hour.",
|
||||
)
|
||||
home_appliance_wh_per_hour: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None,
|
||||
description="The energy consumption of a household appliance in watt-hours per hour.",
|
||||
)
|
||||
kosten_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The costs in euros per hour."
|
||||
)
|
||||
grid_import_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The grid energy drawn in watt-hours per hour."
|
||||
)
|
||||
grid_export_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The energy fed into the grid in watt-hours per hour."
|
||||
)
|
||||
verluste_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The losses in watt-hours per hour."
|
||||
)
|
||||
akku_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None,
|
||||
description="The state of charge of the battery (not the EV) in percentage per hour.",
|
||||
)
|
||||
|
||||
# initialize devices
|
||||
if settings.batteries is not None:
|
||||
for battery_params in settings.batteries:
|
||||
self.add_device(Battery(battery_params))
|
||||
if settings.inverters is not None:
|
||||
for inverter_params in settings.inverters:
|
||||
self.add_device(Inverter(inverter_params))
|
||||
if settings.home_appliances is not None:
|
||||
for home_appliance_params in settings.home_appliances:
|
||||
self.add_device(HomeAppliance(home_appliance_params))
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_balance_euro(self) -> float:
|
||||
"""The total balance of revenues minus costs in euros."""
|
||||
return self.total_revenues_euro - self.total_costs_euro
|
||||
|
||||
self.post_setup()
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_revenues_euro(self) -> float:
|
||||
"""The total revenues in euros."""
|
||||
if self.einnahmen_euro_pro_stunde is None:
|
||||
return 0
|
||||
return np.nansum(self.einnahmen_euro_pro_stunde)
|
||||
|
||||
def post_setup(self) -> None:
|
||||
for device in self.devices.values():
|
||||
device.post_setup()
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_costs_euro(self) -> float:
|
||||
"""The total costs in euros."""
|
||||
if self.kosten_euro_pro_stunde is None:
|
||||
return 0
|
||||
return np.nansum(self.kosten_euro_pro_stunde)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_losses_wh(self) -> float:
|
||||
"""The total losses in watt-hours over the entire period."""
|
||||
if self.verluste_wh_pro_stunde is None:
|
||||
return 0
|
||||
return np.nansum(self.verluste_wh_pro_stunde)
|
||||
|
||||
# Devices
|
||||
# TODO: Make devices class a container of device simulation providers.
|
||||
# Device simulations to be used are then enabled in the configuration.
|
||||
battery: ClassVar[Battery] = Battery(provider_id="GenericBattery")
|
||||
ev: ClassVar[Battery] = Battery(provider_id="GenericBEV")
|
||||
home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher")
|
||||
inverter: ClassVar[Inverter] = Inverter(
|
||||
self_consumption_predictor=SelfConsumptionProbabilityInterpolator,
|
||||
battery=battery,
|
||||
provider_id="GenericInverter",
|
||||
)
|
||||
|
||||
def update_data(self) -> None:
|
||||
"""Update device simulation data."""
|
||||
# Assure devices are set up
|
||||
self.battery.setup()
|
||||
self.ev.setup()
|
||||
self.home_appliance.setup()
|
||||
self.inverter.setup()
|
||||
|
||||
# Pre-allocate arrays for the results, optimized for speed
|
||||
self.last_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan)
|
||||
|
||||
# Set initial state
|
||||
simulation_step = to_duration("1 hour")
|
||||
if self.battery:
|
||||
self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage()
|
||||
if self.ev:
|
||||
self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage()
|
||||
|
||||
# Get predictions for full device simulation time range
|
||||
# gesamtlast[stunde]
|
||||
load_total_mean = self.prediction.key_to_array(
|
||||
"load_total_mean",
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=simulation_step,
|
||||
)
|
||||
# pv_prognose_wh[stunde]
|
||||
pvforecast_ac_power = self.prediction.key_to_array(
|
||||
"pvforecast_ac_power",
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=simulation_step,
|
||||
)
|
||||
# strompreis_euro_pro_wh[stunde]
|
||||
elecprice_marketprice_wh = self.prediction.key_to_array(
|
||||
"elecprice_marketprice_wh",
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=simulation_step,
|
||||
)
|
||||
# einspeiseverguetung_euro_pro_wh_arr[stunde]
|
||||
# TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr
|
||||
einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078)
|
||||
|
||||
for stunde_since_now in range(0, self.total_hours):
|
||||
hour = self.start_datetime.hour + stunde_since_now
|
||||
|
||||
# Accumulate loads and PV generation
|
||||
consumption = load_total_mean[stunde_since_now]
|
||||
self.verluste_wh_pro_stunde[stunde_since_now] = 0.0
|
||||
|
||||
# Home appliances
|
||||
if self.home_appliance:
|
||||
ha_load = self.home_appliance.get_load_for_hour(hour)
|
||||
consumption += ha_load
|
||||
self.home_appliance_wh_per_hour[stunde_since_now] = ha_load
|
||||
|
||||
# E-Auto handling
|
||||
if self.ev:
|
||||
if self.ev_charge_hours[hour] > 0:
|
||||
geladene_menge_eauto, verluste_eauto = self.ev.charge_energy(
|
||||
None, hour, relative_power=self.ev_charge_hours[hour]
|
||||
)
|
||||
consumption += geladene_menge_eauto
|
||||
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto
|
||||
self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage()
|
||||
|
||||
# Process inverter logic
|
||||
grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0)
|
||||
if self.battery:
|
||||
self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour)
|
||||
if self.inverter:
|
||||
generation = pvforecast_ac_power[hour]
|
||||
grid_export, grid_import, losses, self_consumption = self.inverter.process_energy(
|
||||
generation, consumption, hour
|
||||
)
|
||||
|
||||
# AC PV Battery Charge
|
||||
if self.battery and self.ac_charge_hours[hour] > 0.0:
|
||||
self.battery.set_charge_allowed_for_hour(1, hour)
|
||||
geladene_menge, verluste_wh = self.battery.charge_energy(
|
||||
None, hour, relative_power=self.ac_charge_hours[hour]
|
||||
)
|
||||
# print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage())
|
||||
consumption += geladene_menge
|
||||
grid_import += geladene_menge
|
||||
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh
|
||||
|
||||
self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export
|
||||
self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import
|
||||
self.verluste_wh_pro_stunde[stunde_since_now] += losses
|
||||
self.last_wh_pro_stunde[stunde_since_now] = consumption
|
||||
|
||||
# Financial calculations
|
||||
self.kosten_euro_pro_stunde[stunde_since_now] = (
|
||||
grid_import * self.strompreis_euro_pro_wh[hour]
|
||||
)
|
||||
self.einnahmen_euro_pro_stunde[stunde_since_now] = (
|
||||
grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour]
|
||||
)
|
||||
|
||||
# battery SOC tracking
|
||||
if self.battery:
|
||||
self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage()
|
||||
else:
|
||||
self.akku_soc_pro_stunde[stunde_since_now] = 0.0
|
||||
|
||||
def report_dict(self) -> Dict[str, Any]:
|
||||
"""Provides devices simulation output as a dictionary."""
|
||||
out: Dict[str, Optional[Union[np.ndarray, float]]] = {
|
||||
"Last_Wh_pro_Stunde": self.last_wh_pro_stunde,
|
||||
"grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde,
|
||||
"grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde,
|
||||
"Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde,
|
||||
"akku_soc_pro_stunde": self.akku_soc_pro_stunde,
|
||||
"Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde,
|
||||
"Gesamtbilanz_Euro": self.total_balance_euro,
|
||||
"EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde,
|
||||
"Gesamteinnahmen_Euro": self.total_revenues_euro,
|
||||
"Gesamtkosten_Euro": self.total_costs_euro,
|
||||
"Verluste_Pro_Stunde": self.verluste_wh_pro_stunde,
|
||||
"Gesamt_Verluste": self.total_losses_wh,
|
||||
"Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
# Initialize the Devices simulation, it is a singleton.
|
||||
devices: Optional[Devices] = None
|
||||
devices = Devices()
|
||||
|
||||
|
||||
def get_devices() -> Devices:
|
||||
global devices
|
||||
# Fix circular import at runtime
|
||||
if devices is None:
|
||||
devices = Devices()
|
||||
"""Gets the EOS Devices simulation."""
|
||||
return devices
|
||||
|
@@ -1,41 +1,20 @@
|
||||
"""Abstract and base classes for devices."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional, Type
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import Field, computed_field
|
||||
from pydantic import ConfigDict, computed_field
|
||||
|
||||
from akkudoktoreos.core.coreabc import (
|
||||
ConfigMixin,
|
||||
DevicesMixin,
|
||||
EnergyManagementSystemMixin,
|
||||
PredictionMixin,
|
||||
)
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
|
||||
class DeviceParameters(ParametersBaseModel):
|
||||
device_id: str = Field(description="ID of device", examples="device1")
|
||||
hours: Optional[int] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description="Number of prediction hours. Defaults to global config prediction hours.",
|
||||
examples=[None],
|
||||
)
|
||||
|
||||
|
||||
class DeviceOptimizeResult(ParametersBaseModel):
|
||||
device_id: str = Field(description="ID of device", examples=["device1"])
|
||||
hours: int = Field(gt=0, description="Number of hours in the simulation.", examples=[24])
|
||||
|
||||
|
||||
class DeviceState(Enum):
|
||||
UNINITIALIZED = 0
|
||||
PREPARED = 1
|
||||
INITIALIZED = 2
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
||||
@@ -49,16 +28,16 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def end_datetime(self) -> Optional[DateTime]:
|
||||
"""Compute the end datetime based on the `start_datetime` and `hours`.
|
||||
"""Compute the end datetime based on the `start_datetime` and `prediction_hours`.
|
||||
|
||||
Ajusts the calculated end time if DST transitions occur within the prediction window.
|
||||
|
||||
Returns:
|
||||
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
|
||||
"""
|
||||
if self.ems.start_datetime and self.config.prediction.hours:
|
||||
if self.ems.start_datetime and self.config.prediction_hours:
|
||||
end_datetime = self.ems.start_datetime + to_duration(
|
||||
f"{self.config.prediction.hours} hours"
|
||||
f"{self.config.prediction_hours} hours"
|
||||
)
|
||||
dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours
|
||||
logger.debug(
|
||||
@@ -89,93 +68,33 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
||||
return int(duration.total_hours())
|
||||
|
||||
|
||||
class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin):
|
||||
class DeviceBase(DevicesStartEndMixin, PredictionMixin):
|
||||
"""Base class for device simulations.
|
||||
|
||||
Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute
|
||||
`prediction`) and EOS device registry (attribute `devices`).
|
||||
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
|
||||
`prediction`).
|
||||
|
||||
Behavior:
|
||||
- Several initialization phases (setup, post_setup):
|
||||
- setup: Initialize class attributes from DeviceParameters (pydantic input validation)
|
||||
- post_setup: Set connections between devices
|
||||
- NotImplemented:
|
||||
- hooks during optimization
|
||||
|
||||
Notes:
|
||||
- This class is base to concrete devices like battery, inverter, etc. that are used in optimization.
|
||||
- Not a pydantic model for a low footprint during optimization.
|
||||
Note:
|
||||
Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
|
||||
"""
|
||||
|
||||
def __init__(self, parameters: Optional[DeviceParameters] = None):
|
||||
self.device_id: str = "<invalid>"
|
||||
self.parameters: Optional[DeviceParameters] = None
|
||||
self.hours = -1
|
||||
if self.total_hours is not None:
|
||||
self.hours = self.total_hours
|
||||
|
||||
self.initialized = DeviceState.UNINITIALIZED
|
||||
|
||||
if parameters is not None:
|
||||
self.setup(parameters)
|
||||
|
||||
def setup(self, parameters: DeviceParameters) -> None:
|
||||
if self.initialized != DeviceState.UNINITIALIZED:
|
||||
return
|
||||
|
||||
self.parameters = parameters
|
||||
self.device_id = self.parameters.device_id
|
||||
|
||||
if self.parameters.hours is not None:
|
||||
self.hours = self.parameters.hours
|
||||
if self.hours < 0:
|
||||
raise ValueError("hours is unset")
|
||||
|
||||
self._setup()
|
||||
|
||||
self.initialized = DeviceState.PREPARED
|
||||
|
||||
def post_setup(self) -> None:
|
||||
if self.initialized.value >= DeviceState.INITIALIZED.value:
|
||||
return
|
||||
|
||||
self._post_setup()
|
||||
self.initialized = DeviceState.INITIALIZED
|
||||
|
||||
def _setup(self) -> None:
|
||||
"""Implement custom setup in derived device classes."""
|
||||
pass
|
||||
|
||||
def _post_setup(self) -> None:
|
||||
"""Implement custom setup in derived device classes that is run when all devices are initialized."""
|
||||
pass
|
||||
# Disable validation on assignment to speed up simulation runs.
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=False,
|
||||
)
|
||||
|
||||
|
||||
class DevicesBase(DevicesStartEndMixin, PredictionMixin):
|
||||
class DevicesBase(DevicesStartEndMixin, PredictionMixin, PydanticBaseModel):
|
||||
"""Base class for handling device data.
|
||||
|
||||
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
|
||||
`prediction`).
|
||||
|
||||
Note:
|
||||
Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.devices: dict[str, "DeviceBase"] = dict()
|
||||
|
||||
def get_device_by_id(self, device_id: str) -> Optional["DeviceBase"]:
|
||||
return self.devices.get(device_id)
|
||||
|
||||
def add_device(self, device: Optional["DeviceBase"]) -> None:
|
||||
if device is None:
|
||||
return
|
||||
if device.device_id in self.devices:
|
||||
raise ValueError(f"{device.device_id} already registered")
|
||||
self.devices[device.device_id] = device
|
||||
|
||||
def remove_device(self, device: Type["DeviceBase"] | str) -> bool:
|
||||
if isinstance(device, DeviceBase):
|
||||
device = device.device_id
|
||||
return self.devices.pop(device, None) is not None # type: ignore[arg-type]
|
||||
|
||||
def reset(self) -> None:
|
||||
self.devices = dict()
|
||||
# Disable validation on assignment to speed up simulation runs.
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=False,
|
||||
)
|
||||
|
@@ -3,22 +3,21 @@ from typing import Optional
|
||||
import numpy as np
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HomeApplianceParameters(DeviceParameters):
|
||||
"""Home Appliance Device Simulation Configuration."""
|
||||
|
||||
device_id: str = Field(description="ID of home appliance", examples=["dishwasher"])
|
||||
class HomeApplianceParameters(ParametersBaseModel):
|
||||
consumption_wh: int = Field(
|
||||
gt=0,
|
||||
description="An integer representing the energy consumption of a household device in watt-hours.",
|
||||
examples=[2000],
|
||||
)
|
||||
duration_h: int = Field(
|
||||
gt=0,
|
||||
description="An integer representing the usage duration of a household device in hours.",
|
||||
examples=[3],
|
||||
)
|
||||
|
||||
|
||||
@@ -26,16 +25,46 @@ class HomeAppliance(DeviceBase):
|
||||
def __init__(
|
||||
self,
|
||||
parameters: Optional[HomeApplianceParameters] = None,
|
||||
hours: Optional[int] = 24,
|
||||
provider_id: Optional[str] = None,
|
||||
):
|
||||
self.parameters: Optional[HomeApplianceParameters] = None
|
||||
super().__init__(parameters)
|
||||
# Configuration initialisation
|
||||
self.provider_id = provider_id
|
||||
self.prefix = "<invalid>"
|
||||
if self.provider_id == "GenericDishWasher":
|
||||
self.prefix = "dishwasher"
|
||||
# Parameter initialisiation
|
||||
self.parameters = parameters
|
||||
if hours is None:
|
||||
self.hours = self.total_hours
|
||||
else:
|
||||
self.hours = hours
|
||||
|
||||
def _setup(self) -> None:
|
||||
if self.parameters is None:
|
||||
raise ValueError(f"Parameters not set: {self.parameters}")
|
||||
self.initialised = False
|
||||
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
|
||||
if self.parameters is not None:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
if self.initialised:
|
||||
return
|
||||
if self.provider_id is not None:
|
||||
# Setup by configuration
|
||||
self.hours = self.total_hours
|
||||
self.consumption_wh = getattr(self.config, f"{self.prefix}_consumption")
|
||||
self.duration_h = getattr(self.config, f"{self.prefix}_duration")
|
||||
elif self.parameters is not None:
|
||||
# Setup by parameters
|
||||
self.consumption_wh = (
|
||||
self.parameters.consumption_wh
|
||||
) # Total energy consumption of the device in kWh
|
||||
self.duration_h = self.parameters.duration_h # Duration of use in hours
|
||||
else:
|
||||
error_msg = "Parameters and provider ID missing. Can't instantiate."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros
|
||||
self.duration_h = self.parameters.duration_h
|
||||
self.consumption_wh = self.parameters.consumption_wh
|
||||
self.initialised = True
|
||||
|
||||
def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None:
|
||||
"""Sets the start time of the device and generates the corresponding load curve.
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
from typing import List, Sequence
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class Heatpump:
|
||||
MAX_HEAT_OUTPUT = 5000
|
||||
@@ -19,9 +18,10 @@ class Heatpump:
|
||||
COP_COEFFICIENT = 0.1
|
||||
"""COP increase per degree"""
|
||||
|
||||
def __init__(self, max_heat_output: int, hours: int):
|
||||
def __init__(self, max_heat_output: int, prediction_hours: int):
|
||||
self.max_heat_output = max_heat_output
|
||||
self.hours = hours
|
||||
self.prediction_hours = prediction_hours
|
||||
self.log = logging.getLogger(__name__)
|
||||
|
||||
def __check_outside_temperature_range__(self, temp_celsius: float) -> bool:
|
||||
"""Check if temperature is in valid range between -100 and 100 degree Celsius.
|
||||
@@ -58,7 +58,7 @@ class Heatpump:
|
||||
f"Outside temperature '{outside_temperature_celsius}' not in range "
|
||||
"(min: -100 Celsius, max: 100 Celsius)"
|
||||
)
|
||||
logger.error(err_msg)
|
||||
self.log.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
def calculate_heating_output(self, outside_temperature_celsius: float) -> float:
|
||||
@@ -86,7 +86,7 @@ class Heatpump:
|
||||
f"Outside temperature '{outside_temperature_celsius}' not in range "
|
||||
"(min: -100 Celsius, max: 100 Celsius)"
|
||||
)
|
||||
logger.error(err_msg)
|
||||
self.log.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
def calculate_heat_power(self, outside_temperature_celsius: float) -> float:
|
||||
@@ -110,16 +110,16 @@ class Heatpump:
|
||||
f"Outside temperature '{outside_temperature_celsius}' not in range "
|
||||
"(min: -100 Celsius, max: 100 Celsius)"
|
||||
)
|
||||
logger.error(err_msg)
|
||||
self.log.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
def simulate_24h(self, temperatures: Sequence[float]) -> List[float]:
|
||||
"""Simulate power data for 24 hours based on provided temperatures."""
|
||||
power_data: List[float] = []
|
||||
|
||||
if len(temperatures) != self.hours:
|
||||
if len(temperatures) != self.prediction_hours:
|
||||
raise ValueError(
|
||||
f"The temperature array must contain exactly {self.hours} entries, "
|
||||
f"The temperature array must contain exactly {self.prediction_hours} entries, "
|
||||
"one for each hour of the day."
|
||||
)
|
||||
|
||||
|
@@ -1,64 +1,64 @@
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
from scipy.interpolate import RegularGridInterpolator
|
||||
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
|
||||
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.devices.battery import Battery
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InverterParameters(DeviceParameters):
|
||||
"""Inverter Device Simulation Configuration."""
|
||||
|
||||
device_id: str = Field(description="ID of inverter", examples=["inverter1"])
|
||||
max_power_wh: float = Field(gt=0, examples=[10000])
|
||||
battery_id: Optional[str] = Field(
|
||||
default=None, description="ID of battery", examples=[None, "battery1"]
|
||||
)
|
||||
class InverterParameters(ParametersBaseModel):
|
||||
max_power_wh: float = Field(gt=0)
|
||||
|
||||
|
||||
class Inverter(DeviceBase):
|
||||
def __init__(
|
||||
self,
|
||||
self_consumption_predictor: RegularGridInterpolator,
|
||||
parameters: Optional[InverterParameters] = None,
|
||||
battery: Optional[Battery] = None,
|
||||
provider_id: Optional[str] = None,
|
||||
):
|
||||
self.parameters: Optional[InverterParameters] = None
|
||||
super().__init__(parameters)
|
||||
|
||||
self.scr_lookup: dict = {}
|
||||
|
||||
def _calculate_scr(self, consumption: float, generation: float) -> float:
|
||||
"""Check if the consumption and production is in the lookup table. If not, calculate and store the value."""
|
||||
if consumption not in self.scr_lookup:
|
||||
self.scr_lookup[consumption] = {}
|
||||
|
||||
if generation not in self.scr_lookup[consumption]:
|
||||
scr = self.self_consumption_predictor.calculate_self_consumption(
|
||||
consumption, generation
|
||||
)
|
||||
self.scr_lookup[consumption][generation] = scr
|
||||
return scr
|
||||
|
||||
return self.scr_lookup[consumption][generation]
|
||||
|
||||
def _setup(self) -> None:
|
||||
if self.parameters is None:
|
||||
raise ValueError(f"Parameters not set: {self.parameters}")
|
||||
if self.parameters.battery_id is None:
|
||||
# Configuration initialisation
|
||||
self.provider_id = provider_id
|
||||
self.prefix = "<invalid>"
|
||||
if self.provider_id == "GenericInverter":
|
||||
self.prefix = "inverter"
|
||||
# Parameter initialisiation
|
||||
self.parameters = parameters
|
||||
if battery is None:
|
||||
# For the moment raise exception
|
||||
# TODO: Make battery configurable by config
|
||||
error_msg = "Battery for PV inverter is mandatory."
|
||||
logger.error(error_msg)
|
||||
raise NotImplementedError(error_msg)
|
||||
self.self_consumption_predictor = get_eos_load_interpolator()
|
||||
self.max_power_wh = (
|
||||
self.parameters.max_power_wh
|
||||
) # Maximum power that the inverter can handle
|
||||
self.battery = battery # Connection to a battery object
|
||||
self.self_consumption_predictor = self_consumption_predictor
|
||||
|
||||
def _post_setup(self) -> None:
|
||||
if self.parameters is None:
|
||||
raise ValueError(f"Parameters not set: {self.parameters}")
|
||||
self.battery = self.devices.get_device_by_id(self.parameters.battery_id)
|
||||
self.initialised = False
|
||||
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
|
||||
if self.parameters is not None:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
if self.initialised:
|
||||
return
|
||||
if self.provider_id is not None:
|
||||
# Setup by configuration
|
||||
self.max_power_wh = getattr(self.config, f"{self.prefix}_power_max")
|
||||
elif self.parameters is not None:
|
||||
# Setup by parameters
|
||||
self.max_power_wh = (
|
||||
self.parameters.max_power_wh # Maximum power that the inverter can handle
|
||||
)
|
||||
else:
|
||||
error_msg = "Parameters and provider ID missing. Can't instantiate."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
def process_energy(
|
||||
self, generation: float, consumption: float, hour: int
|
||||
@@ -76,8 +76,9 @@ class Inverter(DeviceBase):
|
||||
grid_import = -remaining_power # Negative indicates feeding into the grid
|
||||
self_consumption = self.max_power_wh
|
||||
else:
|
||||
# Calculate scr with lookup table
|
||||
scr = self._calculate_scr(consumption, generation)
|
||||
scr = self.self_consumption_predictor.calculate_self_consumption(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
# Remaining power after consumption
|
||||
remaining_power = (generation - consumption) * scr # EVQ
|
||||
|
@@ -1,24 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.devices.battery import BaseBatteryParameters
|
||||
from akkudoktoreos.devices.generic import HomeApplianceParameters
|
||||
from akkudoktoreos.devices.inverter import InverterParameters
|
||||
|
||||
|
||||
class DevicesCommonSettings(SettingsBaseModel):
|
||||
"""Base configuration for devices simulation settings."""
|
||||
|
||||
batteries: Optional[list[BaseBatteryParameters]] = Field(
|
||||
default=None,
|
||||
description="List of battery/ev devices",
|
||||
examples=[[{"device_id": "battery1", "capacity_wh": 8000}]],
|
||||
)
|
||||
inverters: Optional[list[InverterParameters]] = Field(
|
||||
default=None, description="List of inverters", examples=[[]]
|
||||
)
|
||||
home_appliances: Optional[list[HomeApplianceParameters]] = Field(
|
||||
default=None, description="List of home appliances", examples=[[]]
|
||||
)
|
@@ -9,7 +9,6 @@ The measurements can be added programmatically or imported from a file or JSON s
|
||||
from typing import Any, ClassVar, List, Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from numpydantic import NDArray, Shape
|
||||
from pendulum import DateTime, Duration
|
||||
from pydantic import Field, computed_field
|
||||
@@ -17,26 +16,27 @@ from pydantic import Field, computed_field
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
from akkudoktoreos.core.dataabc import DataImportMixin, DataRecord, DataSequence
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MeasurementCommonSettings(SettingsBaseModel):
|
||||
"""Measurement Configuration."""
|
||||
|
||||
load0_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load0 source", examples=["Household", "Heat Pump"]
|
||||
measurement_load0_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load0 source (e.g. 'Household', 'Heat Pump')"
|
||||
)
|
||||
load1_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load1 source", examples=[None]
|
||||
measurement_load1_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load1 source (e.g. 'Household', 'Heat Pump')"
|
||||
)
|
||||
load2_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load2 source", examples=[None]
|
||||
measurement_load2_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load2 source (e.g. 'Household', 'Heat Pump')"
|
||||
)
|
||||
load3_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load3 source", examples=[None]
|
||||
measurement_load3_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load3 source (e.g. 'Household', 'Heat Pump')"
|
||||
)
|
||||
load4_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load4 source", examples=[None]
|
||||
measurement_load4_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load4 source (e.g. 'Household', 'Heat Pump')"
|
||||
)
|
||||
|
||||
|
||||
@@ -48,42 +48,42 @@ class MeasurementDataRecord(DataRecord):
|
||||
"""
|
||||
|
||||
# Single loads, to be aggregated to total load
|
||||
load0_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load0 meter reading [kWh]", examples=[40421]
|
||||
measurement_load0_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load0 meter reading [kWh]"
|
||||
)
|
||||
load1_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load1 meter reading [kWh]", examples=[None]
|
||||
measurement_load1_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load1 meter reading [kWh]"
|
||||
)
|
||||
load2_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load2 meter reading [kWh]", examples=[None]
|
||||
measurement_load2_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load2 meter reading [kWh]"
|
||||
)
|
||||
load3_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load3 meter reading [kWh]", examples=[None]
|
||||
measurement_load3_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load3 meter reading [kWh]"
|
||||
)
|
||||
load4_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load4 meter reading [kWh]", examples=[None]
|
||||
measurement_load4_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load4 meter reading [kWh]"
|
||||
)
|
||||
|
||||
max_loads: ClassVar[int] = 5 # Maximum number of loads that can be set
|
||||
measurement_max_loads: ClassVar[int] = 5 # Maximum number of loads that can be set
|
||||
|
||||
grid_export_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Export to grid meter reading [kWh]", examples=[1000]
|
||||
measurement_grid_export_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Export to grid meter reading [kWh]"
|
||||
)
|
||||
|
||||
grid_import_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Import from grid meter reading [kWh]", examples=[1000]
|
||||
measurement_grid_import_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Import from grid meter reading [kWh]"
|
||||
)
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def loads(self) -> List[str]:
|
||||
def measurement_loads(self) -> List[str]:
|
||||
"""Compute a list of active loads."""
|
||||
active_loads = []
|
||||
|
||||
# Loop through loadx
|
||||
for i in range(self.max_loads):
|
||||
load_attr = f"load{i}_mr"
|
||||
# Loop through measurement_loadx
|
||||
for i in range(self.measurement_max_loads):
|
||||
load_attr = f"measurement_load{i}_mr"
|
||||
|
||||
# Check if either attribute is set and add to active loads
|
||||
if getattr(self, load_attr, None):
|
||||
@@ -103,14 +103,9 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
||||
)
|
||||
|
||||
topics: ClassVar[List[str]] = [
|
||||
"load",
|
||||
"measurement_load",
|
||||
]
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _interval_count(
|
||||
self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration
|
||||
) -> int:
|
||||
@@ -148,16 +143,11 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
||||
if topic not in self.topics:
|
||||
return None
|
||||
|
||||
topic_keys = [
|
||||
key for key in self.config.measurement.model_fields.keys() if key.startswith(topic)
|
||||
]
|
||||
topic_keys = [key for key in self.config.config_keys if key.startswith(topic)]
|
||||
key = None
|
||||
if topic == "load":
|
||||
if topic == "measurement_load":
|
||||
for config_key in topic_keys:
|
||||
if (
|
||||
config_key.endswith("_name")
|
||||
and getattr(self.config.measurement, config_key) == name
|
||||
):
|
||||
if config_key.endswith("_name") and getattr(self.config, config_key) == name:
|
||||
key = topic + config_key[len(topic) : len(topic) + 1] + "_mr"
|
||||
break
|
||||
|
||||
@@ -253,9 +243,9 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
||||
end_datetime = self[-1].date_time
|
||||
size = self._interval_count(start_datetime, end_datetime, interval)
|
||||
load_total_array = np.zeros(size)
|
||||
# Loop through load<x>_mr
|
||||
for i in range(self.record_class().max_loads):
|
||||
key = f"load{i}_mr"
|
||||
# Loop through measurement_load<x>_mr
|
||||
for i in range(self.record_class().measurement_max_loads):
|
||||
key = f"measurement_load{i}_mr"
|
||||
# Calculate load per interval
|
||||
load_array = self._energy_from_meter_readings(
|
||||
key=key, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from deap import algorithms, base, creator, tools
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -13,7 +14,8 @@ from akkudoktoreos.core.coreabc import (
|
||||
DevicesMixin,
|
||||
EnergyManagementSystemMixin,
|
||||
)
|
||||
from akkudoktoreos.core.ems import EnergyManagementParameters, SimulationResult
|
||||
from akkudoktoreos.core.ems import EnergieManagementSystemParameters, SimulationResult
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.devices.battery import (
|
||||
Battery,
|
||||
@@ -23,11 +25,14 @@ from akkudoktoreos.devices.battery import (
|
||||
)
|
||||
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
||||
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OptimizationParameters(ParametersBaseModel):
|
||||
ems: EnergyManagementParameters
|
||||
ems: EnergieManagementSystemParameters
|
||||
pv_akku: Optional[SolarPanelBatteryParameters]
|
||||
inverter: Optional[InverterParameters]
|
||||
eauto: Optional[ElectricVehicleParameters]
|
||||
@@ -107,8 +112,8 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
):
|
||||
"""Initialize the optimization problem with the required parameters."""
|
||||
self.opti_param: dict[str, Any] = {}
|
||||
self.fixed_eauto_hours = self.config.prediction.hours - self.config.optimization.hours
|
||||
self.possible_charge_values = self.config.optimization.ev_available_charge_rates_percent
|
||||
self.fixed_eauto_hours = self.config.prediction_hours - self.config.optimization_hours
|
||||
self.possible_charge_values = self.config.optimization_ev_available_charge_rates_percent
|
||||
self.verbose = verbose
|
||||
self.fix_seed = fixed_seed
|
||||
self.optimize_ev = True
|
||||
@@ -118,8 +123,8 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
# Set a fixed seed for random operations if provided or in debug mode
|
||||
if self.fix_seed is not None:
|
||||
random.seed(self.fix_seed)
|
||||
elif logger.level == "DEBUG":
|
||||
self.fix_seed = random.randint(1, 100000000000) # noqa: S311
|
||||
elif logger.level == logging.DEBUG:
|
||||
self.fix_seed = random.randint(1, 100000000000)
|
||||
random.seed(self.fix_seed)
|
||||
|
||||
def decode_charge_discharge(
|
||||
@@ -175,23 +180,23 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
total_states = 3 * len_ac
|
||||
|
||||
# 1. Mutating the charge_discharge part
|
||||
charge_discharge_part = individual[: self.config.prediction.hours]
|
||||
charge_discharge_part = individual[: self.config.prediction_hours]
|
||||
(charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part)
|
||||
|
||||
# Instead of a fixed clamping to 0..8 or 0..6 dynamically:
|
||||
charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1)
|
||||
individual[: self.config.prediction.hours] = charge_discharge_mutated
|
||||
individual[: self.config.prediction_hours] = charge_discharge_mutated
|
||||
|
||||
# 2. Mutating the EV charge part, if active
|
||||
if self.optimize_ev:
|
||||
ev_charge_part = individual[
|
||||
self.config.prediction.hours : self.config.prediction.hours * 2
|
||||
self.config.prediction_hours : self.config.prediction_hours * 2
|
||||
]
|
||||
(ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part)
|
||||
ev_charge_part_mutated[self.config.prediction.hours - self.fixed_eauto_hours :] = [
|
||||
ev_charge_part_mutated[self.config.prediction_hours - self.fixed_eauto_hours :] = [
|
||||
0
|
||||
] * self.fixed_eauto_hours
|
||||
individual[self.config.prediction.hours : self.config.prediction.hours * 2] = (
|
||||
individual[self.config.prediction_hours : self.config.prediction_hours * 2] = (
|
||||
ev_charge_part_mutated
|
||||
)
|
||||
|
||||
@@ -207,13 +212,13 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
def create_individual(self) -> list[int]:
|
||||
# Start with discharge states for the individual
|
||||
individual_components = [
|
||||
self.toolbox.attr_discharge_state() for _ in range(self.config.prediction.hours)
|
||||
self.toolbox.attr_discharge_state() for _ in range(self.config.prediction_hours)
|
||||
]
|
||||
|
||||
# Add EV charge index values if optimize_ev is True
|
||||
if self.optimize_ev:
|
||||
individual_components += [
|
||||
self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction.hours)
|
||||
self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction_hours)
|
||||
]
|
||||
|
||||
# Add the start time of the household appliance if it's being optimized
|
||||
@@ -246,7 +251,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
individual.extend(eautocharge_hours_index.tolist())
|
||||
elif self.optimize_ev:
|
||||
# Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu
|
||||
individual.extend([0] * self.config.prediction.hours)
|
||||
individual.extend([0] * self.config.prediction_hours)
|
||||
|
||||
# Add dishwasher start time if applicable
|
||||
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None:
|
||||
@@ -268,13 +273,12 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
3. Dishwasher start time (integer if applicable).
|
||||
"""
|
||||
# Discharge hours as a NumPy array of ints
|
||||
discharge_hours_bin = np.array(individual[: self.config.prediction.hours], dtype=int)
|
||||
discharge_hours_bin = np.array(individual[: self.config.prediction_hours], dtype=int)
|
||||
|
||||
# EV charge hours as a NumPy array of ints (if optimize_ev is True)
|
||||
eautocharge_hours_index = (
|
||||
# append ev charging states to individual
|
||||
np.array(
|
||||
individual[self.config.prediction.hours : self.config.prediction.hours * 2],
|
||||
individual[self.config.prediction_hours : self.config.prediction_hours * 2],
|
||||
dtype=int,
|
||||
)
|
||||
if self.optimize_ev
|
||||
@@ -386,7 +390,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
)
|
||||
self.ems.set_ev_charge_hours(eautocharge_hours_float)
|
||||
else:
|
||||
self.ems.set_ev_charge_hours(np.full(self.config.prediction.hours, 0))
|
||||
self.ems.set_ev_charge_hours(np.full(self.config.prediction_hours, 0))
|
||||
|
||||
return self.ems.simulate(self.ems.start_datetime.hour)
|
||||
|
||||
@@ -448,7 +452,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
# min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size)
|
||||
# battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:]
|
||||
# discharge_hours_bin_tail = discharge_hours_bin[-min_length:]
|
||||
# len_ac = len(self.config.optimization.ev_available_charge_rates_percent)
|
||||
# len_ac = len(self.config.optimization_ev_available_charge_rates_percent)
|
||||
|
||||
# # # Find hours where battery SoC is 0
|
||||
# # zero_soc_mask = battery_soc_per_hour_tail == 0
|
||||
@@ -497,7 +501,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
if parameters.eauto and self.ems.ev
|
||||
else 0
|
||||
)
|
||||
* self.config.optimization.penalty,
|
||||
* self.config.optimization_penalty,
|
||||
)
|
||||
|
||||
return (gesamtbilanz,)
|
||||
@@ -565,26 +569,30 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
start_hour = self.ems.start_datetime.hour
|
||||
|
||||
einspeiseverguetung_euro_pro_wh = np.full(
|
||||
self.config.prediction.hours, parameters.ems.einspeiseverguetung_euro_pro_wh
|
||||
self.config.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh
|
||||
)
|
||||
|
||||
# TODO: Refactor device setup phase out
|
||||
self.devices.reset()
|
||||
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
|
||||
sc = SelfConsumptionProbabilityInterpolator(
|
||||
Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
|
||||
)
|
||||
|
||||
# Initialize PV and EV batteries
|
||||
akku: Optional[Battery] = None
|
||||
if parameters.pv_akku:
|
||||
akku = Battery(parameters.pv_akku)
|
||||
self.devices.add_device(akku)
|
||||
akku.set_charge_per_hour(np.full(self.config.prediction.hours, 1))
|
||||
akku = Battery(
|
||||
parameters.pv_akku,
|
||||
hours=self.config.prediction_hours,
|
||||
)
|
||||
akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1))
|
||||
|
||||
eauto: Optional[Battery] = None
|
||||
if parameters.eauto:
|
||||
eauto = Battery(
|
||||
parameters.eauto,
|
||||
hours=self.config.prediction_hours,
|
||||
)
|
||||
self.devices.add_device(eauto)
|
||||
eauto.set_charge_per_hour(np.full(self.config.prediction.hours, 1))
|
||||
eauto.set_charge_per_hour(np.full(self.config.prediction_hours, 1))
|
||||
self.optimize_ev = (
|
||||
parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0
|
||||
)
|
||||
@@ -595,22 +603,20 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
dishwasher = (
|
||||
HomeAppliance(
|
||||
parameters=parameters.dishwasher,
|
||||
hours=self.config.prediction_hours,
|
||||
)
|
||||
if parameters.dishwasher is not None
|
||||
else None
|
||||
)
|
||||
self.devices.add_device(dishwasher)
|
||||
|
||||
# Initialize the inverter and energy management system
|
||||
inverter: Optional[Inverter] = None
|
||||
if parameters.inverter:
|
||||
inverter = Inverter(
|
||||
sc,
|
||||
parameters.inverter,
|
||||
akku,
|
||||
)
|
||||
self.devices.add_device(inverter)
|
||||
|
||||
self.devices.post_setup()
|
||||
|
||||
self.ems.set_parameters(
|
||||
parameters.ems,
|
||||
inverter=inverter,
|
||||
|
@@ -3,22 +3,27 @@ from typing import List, Optional
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OptimizationCommonSettings(SettingsBaseModel):
|
||||
"""General Optimization Configuration.
|
||||
"""Base configuration for optimization settings.
|
||||
|
||||
Attributes:
|
||||
hours (int): Number of hours for optimizations.
|
||||
optimization_hours (int): Number of hours for optimizations.
|
||||
"""
|
||||
|
||||
hours: Optional[int] = Field(
|
||||
default=48, ge=0, description="Number of hours into the future for optimizations."
|
||||
optimization_hours: Optional[int] = Field(
|
||||
default=24, ge=0, description="Number of hours into the future for optimizations."
|
||||
)
|
||||
|
||||
penalty: Optional[int] = Field(default=10, description="Penalty factor used in optimization.")
|
||||
optimization_penalty: Optional[int] = Field(
|
||||
default=10, description="Penalty factor used in optimization."
|
||||
)
|
||||
|
||||
ev_available_charge_rates_percent: Optional[List[float]] = Field(
|
||||
optimization_ev_available_charge_rates_percent: Optional[List[float]] = Field(
|
||||
default=[
|
||||
0.0,
|
||||
6.0 / 16.0,
|
||||
|
@@ -3,8 +3,11 @@
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OptimizationBase(ConfigMixin, PredictionMixin, PydanticBaseModel):
|
||||
"""Base class for handling optimization data.
|
||||
|
@@ -1,50 +1,14 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
# Valid elecprice providers
|
||||
elecprice_providers = [
|
||||
provider.provider_id()
|
||||
for provider in prediction_eos.providers
|
||||
if isinstance(provider, ElecPriceProvider)
|
||||
]
|
||||
|
||||
|
||||
class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
"""Electricity Price Prediction Configuration."""
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Electricity price provider id of provider to be used.",
|
||||
examples=["ElecPriceAkkudoktor"],
|
||||
elecprice_provider: Optional[str] = Field(
|
||||
default=None, description="Electricity price provider id of provider to be used."
|
||||
)
|
||||
charges_kwh: Optional[float] = Field(
|
||||
default=None, ge=0, description="Electricity price charges (€/kWh).", examples=[0.21]
|
||||
)
|
||||
vat_rate: Optional[float] = Field(
|
||||
default=1.19,
|
||||
ge=0,
|
||||
description="VAT rate factor applied to electricity price when charges are used.",
|
||||
examples=[1.19],
|
||||
)
|
||||
|
||||
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
|
||||
default=None, description="Provider settings", examples=[None]
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("provider", mode="after")
|
||||
@classmethod
|
||||
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None or value in elecprice_providers:
|
||||
return value
|
||||
raise ValueError(
|
||||
f"Provider '{value}' is not a valid electricity price provider: {elecprice_providers}."
|
||||
elecprice_charges_kwh: Optional[float] = Field(
|
||||
default=None, ge=0, description="Electricity price charges (€/kWh)."
|
||||
)
|
||||
|
@@ -9,8 +9,11 @@ from typing import List, Optional
|
||||
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ElecPriceDataRecord(PredictionRecord):
|
||||
"""Represents a electricity price data record containing various price attributes at a specific datetime.
|
||||
@@ -46,15 +49,15 @@ class ElecPriceProvider(PredictionProvider):
|
||||
electricity price_provider (str): Prediction provider for electricity price.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
|
||||
based on `start_datetime` and `historic_hours`.
|
||||
based on `start_datetime` and `prediction_historic_hours`.
|
||||
"""
|
||||
|
||||
# overload
|
||||
@@ -68,4 +71,4 @@ class ElecPriceProvider(PredictionProvider):
|
||||
return "ElecPriceProvider"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self.provider_id() == self.config.elecprice.provider
|
||||
return self.provider_id() == self.config.elecprice_provider
|
||||
|
@@ -11,15 +11,17 @@ from typing import Any, List, Optional, Union
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AkkudoktorElecPriceMeta(PydanticBaseModel):
|
||||
start_timestamp: str
|
||||
@@ -52,11 +54,11 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
prediction_hours (int, optional): Number of hours in the future for the forecast.
|
||||
prediction_historic_hours (int, optional): Number of past hours for retaining data.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`.
|
||||
|
||||
Methods:
|
||||
provider_id(): Returns a unique identifier for the provider.
|
||||
@@ -102,18 +104,17 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
- add the file cache again.
|
||||
"""
|
||||
source = "https://api.akkudoktor.net"
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
assert self.start_datetime # mypy fix
|
||||
# Try to take data from 5 weeks back for prediction
|
||||
date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.general.timezone}"
|
||||
response = requests.get(url, timeout=10)
|
||||
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}"
|
||||
response = requests.get(url)
|
||||
logger.debug(f"Response from {url}: {response}")
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
akkudoktor_data = self._validate_data(response.content)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
return akkudoktor_data
|
||||
|
||||
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
|
||||
@@ -124,16 +125,18 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
capped_data = data.clip(min=lower_bound, max=upper_bound)
|
||||
return capped_data
|
||||
|
||||
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
|
||||
def _predict_ets(
|
||||
self, history: np.ndarray, seasonal_periods: int, prediction_hours: int
|
||||
) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
model = ExponentialSmoothing(
|
||||
clean_history, seasonal="add", seasonal_periods=seasonal_periods
|
||||
).fit()
|
||||
return model.forecast(hours)
|
||||
return model.forecast(prediction_hours)
|
||||
|
||||
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
|
||||
def _predict_median(self, history: np.ndarray, prediction_hours: int) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
return np.full(hours, np.median(clean_history))
|
||||
return np.full(prediction_hours, np.median(clean_history))
|
||||
|
||||
def _update_data(
|
||||
self, force_update: Optional[bool] = False
|
||||
@@ -147,20 +150,19 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
"""
|
||||
# Get Akkudoktor electricity price data
|
||||
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
assert self.start_datetime # mypy fix
|
||||
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
|
||||
# Get charges_kwh in wh
|
||||
charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
|
||||
# Get elecprice_charges_kwh in wh
|
||||
charges_wh = (self.config.elecprice_charges_kwh or 0) / 1000
|
||||
|
||||
highest_orig_datetime = None # newest datetime from the api after that we want to update.
|
||||
series_data = pd.Series(dtype=float) # Initialize an empty series
|
||||
|
||||
for value in akkudoktor_data.values:
|
||||
orig_datetime = to_datetime(value.start, in_timezone=self.config.general.timezone)
|
||||
orig_datetime = to_datetime(value.start, in_timezone=self.config.timezone)
|
||||
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
|
||||
highest_orig_datetime = orig_datetime
|
||||
|
||||
@@ -178,29 +180,30 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
)
|
||||
|
||||
amount_datasets = len(self.records)
|
||||
if not highest_orig_datetime: # mypy fix
|
||||
error_msg = f"Highest original datetime not available: {highest_orig_datetime}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
assert highest_orig_datetime # mypy fix
|
||||
|
||||
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
|
||||
needed_hours = int(
|
||||
self.config.prediction.hours
|
||||
needed_prediction_hours = int(
|
||||
self.config.prediction_hours
|
||||
- ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
|
||||
)
|
||||
|
||||
if needed_hours <= 0:
|
||||
if needed_prediction_hours <= 0:
|
||||
logger.warning(
|
||||
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}"
|
||||
) # this might keep data longer than self.start_datetime + self.config.prediction.hours in the records
|
||||
f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}"
|
||||
) # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records
|
||||
return
|
||||
|
||||
if amount_datasets > 800: # we do the full ets with seasons of 1 week
|
||||
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
|
||||
prediction = self._predict_ets(
|
||||
history, seasonal_periods=168, prediction_hours=needed_prediction_hours
|
||||
)
|
||||
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
|
||||
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
|
||||
prediction = self._predict_ets(
|
||||
history, seasonal_periods=24, prediction_hours=needed_prediction_hours
|
||||
)
|
||||
elif amount_datasets > 0: # not enough data for ets, do median
|
||||
prediction = self._predict_median(history, hours=needed_hours)
|
||||
prediction = self._predict_median(history, prediction_hours=needed_prediction_hours)
|
||||
else:
|
||||
logger.error("No data available for prediction")
|
||||
raise ValueError("No data available")
|
||||
|
@@ -1,257 +0,0 @@
|
||||
"""Retrieves and processes electricity price forecast data from Energy-Charts.
|
||||
|
||||
This module provides classes and mappings to manage electricity price data obtained from the
|
||||
Energy-Charts API, including support for various electricity price attributes such as temperature,
|
||||
humidity, cloud cover, and solar irradiance. The data is mapped to the `ElecPriceDataRecord`
|
||||
format, enabling consistent access to forecasted and historical electricity price attributes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
class EnergyChartsElecPrice(PydanticBaseModel):
|
||||
license_info: str
|
||||
unix_seconds: List[int]
|
||||
price: List[float]
|
||||
unit: str
|
||||
deprecated: bool
|
||||
|
||||
|
||||
class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
"""Fetch and process electricity price forecast data from Energy-Charts.
|
||||
|
||||
ElecPriceEnergyCharts is a singleton-based class that retrieves electricity price forecast data
|
||||
from the Energy-Charts API and maps it to `ElecPriceDataRecord` fields, applying
|
||||
any necessary scaling or unit corrections. It manages the forecast over a range
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
|
||||
|
||||
Methods:
|
||||
provider_id(): Returns a unique identifier for the provider.
|
||||
_request_forecast(): Fetches the forecast from the Energy-Charts API.
|
||||
_update_data(): Processes and updates forecast data from Energy-Charts in ElecPriceDataRecord format.
|
||||
"""
|
||||
|
||||
highest_orig_datetime: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the Energy-Charts provider."""
|
||||
return "ElecPriceEnergyCharts"
|
||||
|
||||
@classmethod
|
||||
def _validate_data(cls, json_str: Union[bytes, Any]) -> EnergyChartsElecPrice:
|
||||
"""Validate Energy-Charts Electricity Price forecast data."""
|
||||
try:
|
||||
energy_charts_data = EnergyChartsElecPrice.model_validate_json(json_str)
|
||||
except ValidationError as e:
|
||||
error_msg = ""
|
||||
for error in e.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
message = error["msg"]
|
||||
error_type = error["type"]
|
||||
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
|
||||
logger.error(f"Energy-Charts schema change: {error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
return energy_charts_data
|
||||
|
||||
@cache_in_file(with_ttl="1 hour")
|
||||
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
|
||||
"""Fetch electricity price forecast data from Energy-Charts API.
|
||||
|
||||
This method sends a request to Energy-Charts API to retrieve forecast data for a specified
|
||||
date range. The response data is parsed and returned as JSON for further processing.
|
||||
|
||||
Returns:
|
||||
dict: The parsed JSON response from Energy-Charts API containing forecast data.
|
||||
|
||||
Raises:
|
||||
ValueError: If the API response does not include expected `electricity price` data.
|
||||
"""
|
||||
source = "https://api.energy-charts.info"
|
||||
if start_date is None:
|
||||
# Try to take data from 5 weeks back for prediction
|
||||
start_date = to_datetime(
|
||||
self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD"
|
||||
)
|
||||
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
url = f"{source}/price?bzn=DE-LU&start={start_date}&end={last_date}"
|
||||
response = requests.get(url, timeout=30)
|
||||
logger.debug(f"Response from {url}: {response}")
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
energy_charts_data = self._validate_data(response.content)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return energy_charts_data
|
||||
|
||||
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
|
||||
# Get charges_kwh in wh
|
||||
charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
|
||||
|
||||
# Initialize
|
||||
highest_orig_datetime = None # newest datetime from the api after that we want to update.
|
||||
series_data = pd.Series(dtype=float) # Initialize an empty series
|
||||
|
||||
# Iterate over timestamps and prices together
|
||||
for unix_sec, price_eur_per_mwh in zip(
|
||||
energy_charts_data.unix_seconds, energy_charts_data.price
|
||||
):
|
||||
orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone)
|
||||
|
||||
# Track the latest datetime
|
||||
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
|
||||
highest_orig_datetime = orig_datetime
|
||||
|
||||
# Convert EUR/MWh to EUR/Wh, apply charges and VAT if charges > 0
|
||||
if charges_wh > 0:
|
||||
vat_rate = self.config.elecprice.vat_rate or 1.19
|
||||
price_wh = ((price_eur_per_mwh / 1_000_000) + charges_wh) * vat_rate
|
||||
else:
|
||||
price_wh = price_eur_per_mwh / 1_000_000
|
||||
|
||||
# Store in series
|
||||
series_data.at[orig_datetime] = price_wh
|
||||
|
||||
return series_data
|
||||
|
||||
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
|
||||
mean = data.mean()
|
||||
std = data.std()
|
||||
lower_bound = mean - sigma * std
|
||||
upper_bound = mean + sigma * std
|
||||
capped_data = data.clip(min=lower_bound, max=upper_bound)
|
||||
return capped_data
|
||||
|
||||
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
model = ExponentialSmoothing(
|
||||
clean_history, seasonal="add", seasonal_periods=seasonal_periods
|
||||
).fit()
|
||||
return model.forecast(hours)
|
||||
|
||||
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
return np.full(hours, np.median(clean_history))
|
||||
|
||||
def _update_data(
|
||||
self, force_update: Optional[bool] = False
|
||||
) -> None: # tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Update forecast data in the ElecPriceDataRecord format.
|
||||
|
||||
Retrieves data from Energy-Charts, maps each Energy-Charts field to the corresponding
|
||||
`ElecPriceDataRecord` and applies any necessary scaling.
|
||||
|
||||
The final mapped and processed data is inserted into the sequence as `ElecPriceDataRecord`.
|
||||
"""
|
||||
# New prices are available every day at 14:00
|
||||
now = pd.Timestamp.now(tz=self.config.general.timezone)
|
||||
midnight = now.normalize()
|
||||
hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47
|
||||
end = midnight + pd.Timedelta(hours=hours_ahead)
|
||||
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
|
||||
# Determine if update is needed and how many days
|
||||
past_days = 35
|
||||
if self.highest_orig_datetime:
|
||||
history_series = self.key_to_series(
|
||||
key="elecprice_marketprice_wh", start_datetime=self.start_datetime
|
||||
)
|
||||
# If history lower, then start_datetime
|
||||
if history_series.index.min() <= self.start_datetime:
|
||||
past_days = 0
|
||||
|
||||
needs_update = end > self.highest_orig_datetime
|
||||
else:
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
logger.info(
|
||||
f"Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
|
||||
)
|
||||
# Set start_date try to take data from 5 weeks back for prediction
|
||||
start_date = to_datetime(
|
||||
self.start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD"
|
||||
)
|
||||
# Get Energy-Charts electricity price data
|
||||
energy_charts_data = self._request_forecast(
|
||||
start_date=start_date, force_update=force_update
|
||||
) # type: ignore
|
||||
|
||||
# Parse and store data
|
||||
series_data = self._parse_data(energy_charts_data)
|
||||
self.highest_orig_datetime = series_data.index.max()
|
||||
self.key_from_series("elecprice_marketprice_wh", series_data)
|
||||
else:
|
||||
logger.info(
|
||||
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
|
||||
)
|
||||
|
||||
# Generate history array for prediction
|
||||
history = self.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
end_datetime=self.highest_orig_datetime,
|
||||
fill_method="linear",
|
||||
)
|
||||
|
||||
amount_datasets = len(self.records)
|
||||
if not self.highest_orig_datetime: # mypy fix
|
||||
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
|
||||
needed_hours = int(
|
||||
self.config.prediction.hours
|
||||
- ((self.highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
|
||||
)
|
||||
|
||||
if needed_hours <= 0:
|
||||
logger.warning(
|
||||
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.start_datetime}"
|
||||
) # this might keep data longer than self.start_datetime + self.config.prediction.hours in the records
|
||||
return
|
||||
|
||||
if amount_datasets > 800: # we do the full ets with seasons of 1 week
|
||||
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
|
||||
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
|
||||
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
|
||||
elif amount_datasets > 0: # not enough data for ets, do median
|
||||
prediction = self._predict_median(history, hours=needed_hours)
|
||||
else:
|
||||
logger.error("No data available for prediction")
|
||||
raise ValueError("No data available")
|
||||
|
||||
# write predictions into the records, update if exist.
|
||||
prediction_series = pd.Series(
|
||||
data=prediction,
|
||||
index=[
|
||||
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
|
||||
for i in range(len(prediction))
|
||||
],
|
||||
)
|
||||
self.key_from_series("elecprice_marketprice_wh", prediction_series)
|
@@ -9,33 +9,34 @@ format, enabling consistent access to forecasted and historical elecprice attrib
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ElecPriceImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for elecprice data import from file or JSON String."""
|
||||
|
||||
import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None,
|
||||
description="Path to the file to import elecprice data from.",
|
||||
examples=[None, "/path/to/prices.json"],
|
||||
elecpriceimport_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None, description="Path to the file to import elecprice data from."
|
||||
)
|
||||
|
||||
import_json: Optional[str] = Field(
|
||||
elecpriceimport_json: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JSON string, dictionary of electricity price forecast value lists.",
|
||||
examples=['{"elecprice_marketprice_wh": [0.0003384, 0.0003318, 0.0003284]}'],
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("import_file_path", mode="after")
|
||||
@field_validator("elecpriceimport_file_path", mode="after")
|
||||
@classmethod
|
||||
def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
|
||||
def validate_elecpriceimport_file_path(
|
||||
cls, value: Optional[Union[str, Path]]
|
||||
) -> Optional[Path]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
@@ -61,15 +62,7 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
|
||||
return "ElecPriceImport"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
if self.config.elecprice.provider_settings is None:
|
||||
logger.debug(f"{self.provider_id()} data update without provider settings.")
|
||||
return
|
||||
if self.config.elecprice.provider_settings.import_file_path:
|
||||
self.import_from_file(
|
||||
self.config.elecprice.provider_settings.import_file_path,
|
||||
key_prefix="elecprice",
|
||||
)
|
||||
if self.config.elecprice.provider_settings.import_json:
|
||||
self.import_from_json(
|
||||
self.config.elecprice.provider_settings.import_json, key_prefix="elecprice"
|
||||
)
|
||||
if self.config.elecpriceimport_file_path is not None:
|
||||
self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice")
|
||||
if self.config.elecpriceimport_json is not None:
|
||||
self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice")
|
||||
|
@@ -6,15 +6,13 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.interpolate import RegularGridInterpolator
|
||||
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
|
||||
|
||||
class SelfConsumptionProbabilityInterpolator:
|
||||
def __init__(self, filepath: str | Path):
|
||||
self.filepath = filepath
|
||||
# Load the RegularGridInterpolator
|
||||
with open(self.filepath, "rb") as file:
|
||||
self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301
|
||||
self.interpolator: RegularGridInterpolator = pickle.load(file)
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def generate_points(
|
||||
@@ -69,17 +67,5 @@ class SelfConsumptionProbabilityInterpolator:
|
||||
# return self_consumption_rate
|
||||
|
||||
|
||||
class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin):
|
||||
def __init__(self) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
filename = Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
|
||||
super().__init__(filename)
|
||||
|
||||
|
||||
# Initialize the Energy Management System, it is a singleton.
|
||||
eos_load_interpolator = EOSLoadInterpolator()
|
||||
|
||||
|
||||
def get_eos_load_interpolator() -> EOSLoadInterpolator:
|
||||
return eos_load_interpolator
|
||||
# Test the function
|
||||
# print(calculate_self_consumption(1000, 1200))
|
||||
|
@@ -1,43 +1,18 @@
|
||||
"""Load forecast module for load predictions."""
|
||||
|
||||
from typing import Optional, Union
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||
from akkudoktoreos.prediction.loadvrm import LoadVrmCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
# Valid load providers
|
||||
load_providers = [
|
||||
provider.provider_id()
|
||||
for provider in prediction_eos.providers
|
||||
if isinstance(provider, LoadProvider)
|
||||
]
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoadCommonSettings(SettingsBaseModel):
|
||||
"""Load Prediction Configuration."""
|
||||
"""Common settings for loaod forecast providers."""
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Load provider id of provider to be used.",
|
||||
examples=["LoadAkkudoktor"],
|
||||
load_provider: Optional[str] = Field(
|
||||
default=None, description="Load provider id of provider to be used."
|
||||
)
|
||||
|
||||
provider_settings: Optional[
|
||||
Union[LoadAkkudoktorCommonSettings, LoadVrmCommonSettings, LoadImportCommonSettings]
|
||||
] = Field(default=None, description="Provider settings", examples=[None])
|
||||
|
||||
# Validators
|
||||
@field_validator("provider", mode="after")
|
||||
@classmethod
|
||||
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None or value in load_providers:
|
||||
return value
|
||||
raise ValueError(f"Provider '{value}' is not a valid load provider: {load_providers}.")
|
||||
|
@@ -9,8 +9,11 @@ from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoadDataRecord(PredictionRecord):
|
||||
"""Represents a load data record containing various load attributes at a specific datetime."""
|
||||
@@ -30,18 +33,18 @@ class LoadProvider(PredictionProvider):
|
||||
LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
|
||||
Configuration variables:
|
||||
provider (str): Prediction provider for load.
|
||||
load_provider (str): Prediction provider for load.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
|
||||
based on `start_datetime` and `historic_hours`.
|
||||
based on `start_datetime` and `prediction_historic_hours`.
|
||||
"""
|
||||
|
||||
# overload
|
||||
@@ -55,4 +58,4 @@ class LoadProvider(PredictionProvider):
|
||||
return "LoadProvider"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self.provider_id() == self.config.load.provider
|
||||
return self.provider_id() == self.config.load_provider
|
||||
|
@@ -3,19 +3,21 @@
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoadAkkudoktorCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for load data import from file."""
|
||||
|
||||
loadakkudoktor_year_energy: Optional[float] = Field(
|
||||
default=None, description="Yearly energy consumption (kWh).", examples=[40421]
|
||||
default=None, description="Yearly energy consumption (kWh)."
|
||||
)
|
||||
|
||||
|
||||
@@ -89,9 +91,7 @@ class LoadAkkudoktor(LoadProvider):
|
||||
list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"]))
|
||||
)
|
||||
# Calculate values in W by relative profile data and yearly consumption given in kWh
|
||||
data_year_energy = (
|
||||
profile_data * self.config.load.provider_settings.loadakkudoktor_year_energy * 1000
|
||||
)
|
||||
data_year_energy = profile_data * self.config.loadakkudoktor_year_energy * 1000
|
||||
except FileNotFoundError:
|
||||
error_msg = f"Error: File {load_file} not found."
|
||||
logger.error(error_msg)
|
||||
@@ -109,7 +109,7 @@ class LoadAkkudoktor(LoadProvider):
|
||||
# We provide prediction starting at start of day, to be compatible to old system.
|
||||
# End date for prediction is prediction hours from now.
|
||||
date = self.start_datetime.start_of("day")
|
||||
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
|
||||
end_date = self.start_datetime.add(hours=self.config.prediction_hours)
|
||||
while compare_datetimes(date, end_date).lt:
|
||||
# Extract mean (index 0) and standard deviation (index 1) for the given day and hour
|
||||
# Day indexing starts at 0, -1 because of that
|
||||
@@ -120,12 +120,11 @@ class LoadAkkudoktor(LoadProvider):
|
||||
}
|
||||
if date.day_of_week < 5:
|
||||
# Monday to Friday (0..4)
|
||||
value_adjusted = hourly_stats[0] + weekday_adjust[date.hour]
|
||||
values["load_mean_adjusted"] = hourly_stats[0] + weekday_adjust[date.hour]
|
||||
else:
|
||||
# Saturday, Sunday (5, 6)
|
||||
value_adjusted = hourly_stats[0] + weekend_adjust[date.hour]
|
||||
values["load_mean_adjusted"] = max(0, value_adjusted)
|
||||
values["load_mean_adjusted"] = hourly_stats[0] + weekend_adjust[date.hour]
|
||||
self.update_value(date, values)
|
||||
date += to_duration("1 hour")
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
|
@@ -9,30 +9,28 @@ format, enabling consistent access to forecasted and historical load attributes.
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoadImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for load data import from file or JSON string."""
|
||||
|
||||
import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None,
|
||||
description="Path to the file to import load data from.",
|
||||
examples=[None, "/path/to/yearly_load.json"],
|
||||
load_import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None, description="Path to the file to import load data from."
|
||||
)
|
||||
import_json: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JSON string, dictionary of load forecast value lists.",
|
||||
examples=['{"load0_mean": [676.71, 876.19, 527.13]}'],
|
||||
load_import_json: Optional[str] = Field(
|
||||
default=None, description="JSON string, dictionary of load forecast value lists."
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("import_file_path", mode="after")
|
||||
@field_validator("load_import_file_path", mode="after")
|
||||
@classmethod
|
||||
def validate_loadimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
|
||||
if value is None:
|
||||
@@ -60,10 +58,7 @@ class LoadImport(LoadProvider, PredictionImportProvider):
|
||||
return "LoadImport"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
if self.config.load.provider_settings is None:
|
||||
logger.debug(f"{self.provider_id()} data update without provider settings.")
|
||||
return
|
||||
if self.config.load.provider_settings.import_file_path:
|
||||
self.import_from_file(self.config.provider_settings.import_file_path, key_prefix="load")
|
||||
if self.config.load.provider_settings.import_json:
|
||||
self.import_from_json(self.config.load.provider_settings.import_json, key_prefix="load")
|
||||
if self.config.load_import_file_path is not None:
|
||||
self.import_from_file(self.config.load_import_file_path, key_prefix="load")
|
||||
if self.config.load_import_json is not None:
|
||||
self.import_from_json(self.config.load_import_json, key_prefix="load")
|
||||
|
@@ -1,109 +0,0 @@
|
||||
"""Retrieves load forecast data from VRM API."""
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import Field, ValidationError
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
class VrmForecastRecords(PydanticBaseModel):
|
||||
vrm_consumption_fc: list[tuple[int, float]]
|
||||
solar_yield_forecast: list[tuple[int, float]]
|
||||
|
||||
|
||||
class VrmForecastResponse(PydanticBaseModel):
|
||||
success: bool
|
||||
records: VrmForecastRecords
|
||||
totals: dict
|
||||
|
||||
|
||||
class LoadVrmCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for VRM API."""
|
||||
|
||||
load_vrm_token: str = Field(
|
||||
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
|
||||
)
|
||||
load_vrm_idsite: int = Field(default=12345, description="VRM-Installation-ID", examples=[12345])
|
||||
|
||||
|
||||
class LoadVrm(LoadProvider):
|
||||
"""Fetch Load forecast data from VRM API."""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
return "LoadVrm"
|
||||
|
||||
@classmethod
|
||||
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
|
||||
"""Validate the VRM API load forecast response."""
|
||||
try:
|
||||
return VrmForecastResponse.model_validate_json(json_str)
|
||||
except ValidationError as e:
|
||||
error_msg = "\n".join(
|
||||
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
|
||||
f"Error: {err['msg']}\nType: {err['type']}"
|
||||
for err in e.errors()
|
||||
)
|
||||
logger.error(f"VRM-API schema validation failed:\n{error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
|
||||
"""Fetch forecast data from Victron VRM API."""
|
||||
base_url = "https://vrmapi.victronenergy.com/v2/installations"
|
||||
installation_id = self.config.load.provider_settings.load_vrm_idsite
|
||||
api_token = self.config.load.provider_settings.load_vrm_token
|
||||
|
||||
url = f"{base_url}/{installation_id}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
|
||||
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
|
||||
|
||||
logger.debug(f"Requesting VRM load forecast: {url}")
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error during VRM API request: {e}")
|
||||
raise RuntimeError("Failed to fetch load forecast from VRM API") from e
|
||||
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return self._validate_data(response.content)
|
||||
|
||||
def _ts_to_datetime(self, timestamp: int) -> DateTime:
|
||||
"""Convert UNIX ms timestamp to timezone-aware datetime."""
|
||||
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Fetch and store VRM load forecast as load_mean and related values."""
|
||||
start_date = self.start_datetime.start_of("day")
|
||||
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
|
||||
start_ts = int(start_date.timestamp())
|
||||
end_ts = int(end_date.timestamp())
|
||||
|
||||
logger.info(f"Updating Load forecast from VRM: {start_date} to {end_date}")
|
||||
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
|
||||
|
||||
load_mean_data = []
|
||||
for timestamp, value in vrm_forecast_data.records.vrm_consumption_fc:
|
||||
date = self._ts_to_datetime(timestamp)
|
||||
rounded_value = round(value, 2)
|
||||
|
||||
self.update_value(
|
||||
date,
|
||||
{"load_mean": rounded_value, "load_std": 0.0, "load_mean_adjusted": rounded_value},
|
||||
)
|
||||
|
||||
load_mean_data.append((date, rounded_value))
|
||||
|
||||
logger.debug(f"Updated load_mean with {len(load_mean_data)} entries.")
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
lv = LoadVrm()
|
||||
lv._update_data()
|
@@ -28,50 +28,78 @@ Attributes:
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
|
||||
from akkudoktoreos.prediction.loadimport import LoadImport
|
||||
from akkudoktoreos.prediction.loadvrm import LoadVrm
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionContainer
|
||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
|
||||
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
||||
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||
from akkudoktoreos.prediction.weatherimport import WeatherImport
|
||||
from akkudoktoreos.utils.datetimeutil import to_timezone
|
||||
|
||||
|
||||
class PredictionCommonSettings(SettingsBaseModel):
|
||||
"""General Prediction Configuration.
|
||||
"""Base configuration for prediction settings, including forecast duration, geographic location, and time zone.
|
||||
|
||||
This class provides configuration for prediction settings, allowing users to specify
|
||||
parameters such as the forecast duration (in hours).
|
||||
Validators ensure each parameter is within a specified range.
|
||||
parameters such as the forecast duration (in hours) and location (latitude and longitude).
|
||||
Validators ensure each parameter is within a specified range. A computed property, `timezone`,
|
||||
determines the time zone based on latitude and longitude.
|
||||
|
||||
Attributes:
|
||||
hours (Optional[int]): Number of hours into the future for predictions.
|
||||
prediction_hours (Optional[int]): Number of hours into the future for predictions.
|
||||
Must be non-negative.
|
||||
historic_hours (Optional[int]): Number of hours into the past for historical data.
|
||||
prediction_historic_hours (Optional[int]): Number of hours into the past for historical data.
|
||||
Must be non-negative.
|
||||
latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.
|
||||
longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.
|
||||
|
||||
Properties:
|
||||
timezone (Optional[str]): Computed time zone string based on the specified latitude
|
||||
and longitude.
|
||||
|
||||
Validators:
|
||||
validate_hours (int): Ensures `hours` is a non-negative integer.
|
||||
validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.
|
||||
validate_prediction_hours (int): Ensures `prediction_hours` is a non-negative integer.
|
||||
validate_prediction_historic_hours (int): Ensures `prediction_historic_hours` is a non-negative integer.
|
||||
validate_latitude (float): Ensures `latitude` is within the range -90 to 90.
|
||||
validate_longitude (float): Ensures `longitude` is within the range -180 to 180.
|
||||
"""
|
||||
|
||||
hours: Optional[int] = Field(
|
||||
prediction_hours: Optional[int] = Field(
|
||||
default=48, ge=0, description="Number of hours into the future for predictions"
|
||||
)
|
||||
historic_hours: Optional[int] = Field(
|
||||
prediction_historic_hours: Optional[int] = Field(
|
||||
default=48,
|
||||
ge=0,
|
||||
description="Number of hours into the past for historical predictions data",
|
||||
)
|
||||
latitude: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=-90.0,
|
||||
le=90.0,
|
||||
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
|
||||
)
|
||||
longitude: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=-180.0,
|
||||
le=180.0,
|
||||
description="Longitude in decimal degrees, within -180 to 180 (°)",
|
||||
)
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def timezone(self) -> Optional[str]:
|
||||
"""Compute timezone based on latitude and longitude."""
|
||||
if self.latitude and self.longitude:
|
||||
return to_timezone(location=(self.latitude, self.longitude), as_string=True)
|
||||
return None
|
||||
|
||||
|
||||
class Prediction(PredictionContainer):
|
||||
@@ -86,13 +114,10 @@ class Prediction(PredictionContainer):
|
||||
providers: List[
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceImport,
|
||||
LoadAkkudoktor,
|
||||
LoadVrm,
|
||||
LoadImport,
|
||||
PVForecastAkkudoktor,
|
||||
PVForecastVrm,
|
||||
PVForecastImport,
|
||||
WeatherBrightSky,
|
||||
WeatherClearOutside,
|
||||
@@ -103,13 +128,10 @@ class Prediction(PredictionContainer):
|
||||
|
||||
# Initialize forecast providers, all are singletons.
|
||||
elecprice_akkudoktor = ElecPriceAkkudoktor()
|
||||
elecprice_energy_charts = ElecPriceEnergyCharts()
|
||||
elecprice_import = ElecPriceImport()
|
||||
load_akkudoktor = LoadAkkudoktor()
|
||||
load_vrm = LoadVrm()
|
||||
load_import = LoadImport()
|
||||
pvforecast_akkudoktor = PVForecastAkkudoktor()
|
||||
pvforecast_vrm = PVForecastVrm()
|
||||
pvforecast_import = PVForecastImport()
|
||||
weather_brightsky = WeatherBrightSky()
|
||||
weather_clearoutside = WeatherClearOutside()
|
||||
@@ -123,13 +145,10 @@ def get_prediction() -> Prediction:
|
||||
prediction = Prediction(
|
||||
providers=[
|
||||
elecprice_akkudoktor,
|
||||
elecprice_energy_charts,
|
||||
elecprice_import,
|
||||
load_akkudoktor,
|
||||
load_vrm,
|
||||
load_import,
|
||||
pvforecast_akkudoktor,
|
||||
pvforecast_vrm,
|
||||
pvforecast_import,
|
||||
weather_brightsky,
|
||||
weather_clearoutside,
|
||||
|
@@ -10,7 +10,6 @@ and manipulation of configuration and prediction data in a clear, scalable, and
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
@@ -23,8 +22,11 @@ from akkudoktoreos.core.dataabc import (
|
||||
DataRecord,
|
||||
DataSequence,
|
||||
)
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PredictionBase(DataBase, MeasurementMixin):
|
||||
"""Base class for handling prediction data.
|
||||
@@ -112,16 +114,16 @@ class PredictionStartEndKeepMixin(PredictionBase):
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def end_datetime(self) -> Optional[DateTime]:
|
||||
"""Compute the end datetime based on the `start_datetime` and `hours`.
|
||||
"""Compute the end datetime based on the `start_datetime` and `prediction_hours`.
|
||||
|
||||
Ajusts the calculated end time if DST transitions occur within the prediction window.
|
||||
|
||||
Returns:
|
||||
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
|
||||
"""
|
||||
if self.start_datetime and self.config.prediction.hours:
|
||||
if self.start_datetime and self.config.prediction_hours:
|
||||
end_datetime = self.start_datetime + to_duration(
|
||||
f"{self.config.prediction.hours} hours"
|
||||
f"{self.config.prediction_hours} hours"
|
||||
)
|
||||
dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours
|
||||
logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}")
|
||||
@@ -145,10 +147,10 @@ class PredictionStartEndKeepMixin(PredictionBase):
|
||||
return None
|
||||
historic_hours = self.historic_hours_min()
|
||||
if (
|
||||
self.config.prediction.historic_hours
|
||||
and self.config.prediction.historic_hours > historic_hours
|
||||
self.config.prediction_historic_hours
|
||||
and self.config.prediction_historic_hours > historic_hours
|
||||
):
|
||||
historic_hours = int(self.config.prediction.historic_hours)
|
||||
historic_hours = int(self.config.prediction_historic_hours)
|
||||
return self.start_datetime - to_duration(f"{historic_hours} hours")
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@@ -204,6 +206,9 @@ class PredictionProvider(PredictionStartEndKeepMixin, DataProvider):
|
||||
force_enable (bool, optional): If True, forces the update even if the provider is disabled.
|
||||
force_update (bool, optional): If True, forces the provider to update the data even if still cached.
|
||||
"""
|
||||
# Update prediction configuration
|
||||
self.config.update()
|
||||
|
||||
# Check after configuration is updated.
|
||||
if not force_enable and not self.enabled():
|
||||
return
|
||||
|
@@ -1,176 +1,397 @@
|
||||
"""PV forecast module for PV power predictions."""
|
||||
|
||||
from typing import Any, List, Optional, Self, Union
|
||||
from typing import Any, ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field, computed_field, field_validator, model_validator
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVforecastVrmCommonSettings
|
||||
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
# Valid PV forecast providers
|
||||
pvforecast_providers = [
|
||||
provider.provider_id()
|
||||
for provider in prediction_eos.providers
|
||||
if isinstance(provider, PVForecastProvider)
|
||||
]
|
||||
|
||||
|
||||
class PVForecastPlaneSetting(SettingsBaseModel):
|
||||
"""PV Forecast Plane Configuration."""
|
||||
|
||||
# latitude: Optional[float] = Field(default=None, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)")
|
||||
surface_tilt: Optional[float] = Field(
|
||||
default=30.0,
|
||||
ge=0.0,
|
||||
le=90.0,
|
||||
description="Tilt angle from horizontal plane. Ignored for two-axis tracking.",
|
||||
examples=[10.0, 20.0],
|
||||
)
|
||||
surface_azimuth: Optional[float] = Field(
|
||||
default=180.0,
|
||||
ge=0.0,
|
||||
le=360.0,
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
examples=[180.0, 90.0],
|
||||
)
|
||||
userhorizon: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
|
||||
examples=[[10.0, 20.0, 30.0], [5.0, 15.0, 25.0]],
|
||||
)
|
||||
peakpower: Optional[float] = Field(
|
||||
default=None, description="Nominal power of PV system in kW.", examples=[5.0, 3.5]
|
||||
)
|
||||
pvtechchoice: Optional[str] = Field(
|
||||
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
|
||||
)
|
||||
mountingplace: Optional[str] = Field(
|
||||
default="free",
|
||||
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
|
||||
)
|
||||
loss: Optional[float] = Field(default=14.0, description="Sum of PV system losses in percent")
|
||||
trackingtype: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=5,
|
||||
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
|
||||
examples=[0, 1, 2, 3, 4, 5],
|
||||
)
|
||||
optimal_surface_tilt: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
|
||||
examples=[False],
|
||||
)
|
||||
optimalangles: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
|
||||
examples=[False],
|
||||
)
|
||||
albedo: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Proportion of the light hitting the ground that it reflects back.",
|
||||
examples=[None],
|
||||
)
|
||||
module_model: Optional[str] = Field(
|
||||
default=None, description="Model of the PV modules of this plane.", examples=[None]
|
||||
)
|
||||
inverter_model: Optional[str] = Field(
|
||||
default=None, description="Model of the inverter of this plane.", examples=[None]
|
||||
)
|
||||
inverter_paco: Optional[int] = Field(
|
||||
default=None, description="AC power rating of the inverter [W].", examples=[6000, 4000]
|
||||
)
|
||||
modules_per_string: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Number of the PV modules of the strings of this plane.",
|
||||
examples=[20],
|
||||
)
|
||||
strings_per_inverter: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Number of the strings of the inverter of this plane.",
|
||||
examples=[2],
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_list_length(self) -> Self:
|
||||
# Check if either attribute is set and add to active planes
|
||||
if self.trackingtype == 2:
|
||||
# Tilt angle from horizontal plane is ignored for two-axis tracking.
|
||||
if self.surface_azimuth is None:
|
||||
raise ValueError("If trackingtype is set, azimuth must be set as well.")
|
||||
elif self.surface_tilt is None or self.surface_azimuth is None:
|
||||
raise ValueError("surface_tilt and surface_azimuth must be set.")
|
||||
return self
|
||||
|
||||
@field_validator("mountingplace")
|
||||
def validate_mountingplace(cls, mountingplace: Optional[str]) -> Optional[str]:
|
||||
if mountingplace is not None and mountingplace not in ["free", "building"]:
|
||||
raise ValueError(f"Invalid mountingplace: {mountingplace}")
|
||||
return mountingplace
|
||||
|
||||
@field_validator("pvtechchoice")
|
||||
def validate_pvtechchoice(cls, pvtechchoice: Optional[str]) -> Optional[str]:
|
||||
if pvtechchoice is not None and pvtechchoice not in ["crystSi", "CIS", "CdTe", "Unknown"]:
|
||||
raise ValueError(f"Invalid pvtechchoice: {pvtechchoice}")
|
||||
return pvtechchoice
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PVForecastCommonSettings(SettingsBaseModel):
|
||||
"""PV Forecast Configuration."""
|
||||
|
||||
# General plane parameters
|
||||
# https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html
|
||||
# Inverter Parameters
|
||||
# https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/inverter.html
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
pvforecast_provider: Optional[str] = Field(
|
||||
default=None, description="PVForecast provider id of provider to be used."
|
||||
)
|
||||
# pvforecast0_latitude: Optional[float] = Field(default=None, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)")
|
||||
# Plane 0
|
||||
pvforecast0_surface_tilt: Optional[float] = Field(
|
||||
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
|
||||
)
|
||||
pvforecast0_surface_azimuth: Optional[float] = Field(
|
||||
default=None,
|
||||
description="PVForecast provider id of provider to be used.",
|
||||
examples=["PVForecastAkkudoktor"],
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
)
|
||||
|
||||
provider_settings: Optional[
|
||||
Union[PVForecastImportCommonSettings, PVforecastVrmCommonSettings]
|
||||
] = Field(default=None, description="Provider settings", examples=[None])
|
||||
|
||||
planes: Optional[list[PVForecastPlaneSetting]] = Field(
|
||||
pvforecast0_userhorizon: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Plane configuration.",
|
||||
examples=[get_model_structure_from_examples(PVForecastPlaneSetting, True)],
|
||||
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
|
||||
)
|
||||
pvforecast0_peakpower: Optional[float] = Field(
|
||||
default=None, description="Nominal power of PV system in kW."
|
||||
)
|
||||
pvforecast0_pvtechchoice: Optional[str] = Field(
|
||||
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
|
||||
)
|
||||
pvforecast0_mountingplace: Optional[str] = Field(
|
||||
default="free",
|
||||
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
|
||||
)
|
||||
pvforecast0_loss: Optional[float] = Field(
|
||||
default=14.0, description="Sum of PV system losses in percent"
|
||||
)
|
||||
pvforecast0_trackingtype: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
|
||||
)
|
||||
pvforecast0_optimal_surface_tilt: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast0_optimalangles: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast0_albedo: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Proportion of the light hitting the ground that it reflects back.",
|
||||
)
|
||||
pvforecast0_module_model: Optional[str] = Field(
|
||||
default=None, description="Model of the PV modules of this plane."
|
||||
)
|
||||
pvforecast0_inverter_model: Optional[str] = Field(
|
||||
default=None, description="Model of the inverter of this plane."
|
||||
)
|
||||
pvforecast0_inverter_paco: Optional[int] = Field(
|
||||
default=None, description="AC power rating of the inverter. [W]"
|
||||
)
|
||||
pvforecast0_modules_per_string: Optional[int] = Field(
|
||||
default=None, description="Number of the PV modules of the strings of this plane."
|
||||
)
|
||||
pvforecast0_strings_per_inverter: Optional[int] = Field(
|
||||
default=None, description="Number of the strings of the inverter of this plane."
|
||||
)
|
||||
# Plane 1
|
||||
pvforecast1_surface_tilt: Optional[float] = Field(
|
||||
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
|
||||
)
|
||||
pvforecast1_surface_azimuth: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
)
|
||||
pvforecast1_userhorizon: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
|
||||
)
|
||||
pvforecast1_peakpower: Optional[float] = Field(
|
||||
default=None, description="Nominal power of PV system in kW."
|
||||
)
|
||||
pvforecast1_pvtechchoice: Optional[str] = Field(
|
||||
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
|
||||
)
|
||||
pvforecast1_mountingplace: Optional[str] = Field(
|
||||
default="free",
|
||||
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
|
||||
)
|
||||
pvforecast1_loss: Optional[float] = Field(
|
||||
default=14.0, description="Sum of PV system losses in percent"
|
||||
)
|
||||
pvforecast1_trackingtype: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
|
||||
)
|
||||
pvforecast1_optimal_surface_tilt: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast1_optimalangles: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast1_albedo: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Proportion of the light hitting the ground that it reflects back.",
|
||||
)
|
||||
pvforecast1_module_model: Optional[str] = Field(
|
||||
default=None, description="Model of the PV modules of this plane."
|
||||
)
|
||||
pvforecast1_inverter_model: Optional[str] = Field(
|
||||
default=None, description="Model of the inverter of this plane."
|
||||
)
|
||||
pvforecast1_inverter_paco: Optional[int] = Field(
|
||||
default=None, description="AC power rating of the inverter. [W]"
|
||||
)
|
||||
pvforecast1_modules_per_string: Optional[int] = Field(
|
||||
default=None, description="Number of the PV modules of the strings of this plane."
|
||||
)
|
||||
pvforecast1_strings_per_inverter: Optional[int] = Field(
|
||||
default=None, description="Number of the strings of the inverter of this plane."
|
||||
)
|
||||
# Plane 2
|
||||
pvforecast2_surface_tilt: Optional[float] = Field(
|
||||
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
|
||||
)
|
||||
pvforecast2_surface_azimuth: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
)
|
||||
pvforecast2_userhorizon: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
|
||||
)
|
||||
pvforecast2_peakpower: Optional[float] = Field(
|
||||
default=None, description="Nominal power of PV system in kW."
|
||||
)
|
||||
pvforecast2_pvtechchoice: Optional[str] = Field(
|
||||
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
|
||||
)
|
||||
pvforecast2_mountingplace: Optional[str] = Field(
|
||||
default="free",
|
||||
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
|
||||
)
|
||||
pvforecast2_loss: Optional[float] = Field(
|
||||
default=14.0, description="Sum of PV system losses in percent"
|
||||
)
|
||||
pvforecast2_trackingtype: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
|
||||
)
|
||||
pvforecast2_optimal_surface_tilt: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast2_optimalangles: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast2_albedo: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Proportion of the light hitting the ground that it reflects back.",
|
||||
)
|
||||
pvforecast2_module_model: Optional[str] = Field(
|
||||
default=None, description="Model of the PV modules of this plane."
|
||||
)
|
||||
pvforecast2_inverter_model: Optional[str] = Field(
|
||||
default=None, description="Model of the inverter of this plane."
|
||||
)
|
||||
pvforecast2_inverter_paco: Optional[int] = Field(
|
||||
default=None, description="AC power rating of the inverter. [W]"
|
||||
)
|
||||
pvforecast2_modules_per_string: Optional[int] = Field(
|
||||
default=None, description="Number of the PV modules of the strings of this plane."
|
||||
)
|
||||
pvforecast2_strings_per_inverter: Optional[int] = Field(
|
||||
default=None, description="Number of the strings of the inverter of this plane."
|
||||
)
|
||||
# Plane 3
|
||||
pvforecast3_surface_tilt: Optional[float] = Field(
|
||||
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
|
||||
)
|
||||
pvforecast3_surface_azimuth: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
)
|
||||
pvforecast3_userhorizon: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
|
||||
)
|
||||
pvforecast3_peakpower: Optional[float] = Field(
|
||||
default=None, description="Nominal power of PV system in kW."
|
||||
)
|
||||
pvforecast3_pvtechchoice: Optional[str] = Field(
|
||||
default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
|
||||
)
|
||||
pvforecast3_mountingplace: Optional[str] = Field(
|
||||
default="free",
|
||||
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
|
||||
)
|
||||
pvforecast3_loss: Optional[float] = Field(
|
||||
default=14.0, description="Sum of PV system losses in percent"
|
||||
)
|
||||
pvforecast3_trackingtype: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
|
||||
)
|
||||
pvforecast3_optimal_surface_tilt: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast3_optimalangles: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast3_albedo: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Proportion of the light hitting the ground that it reflects back.",
|
||||
)
|
||||
pvforecast3_module_model: Optional[str] = Field(
|
||||
default=None, description="Model of the PV modules of this plane."
|
||||
)
|
||||
pvforecast3_inverter_model: Optional[str] = Field(
|
||||
default=None, description="Model of the inverter of this plane."
|
||||
)
|
||||
pvforecast3_inverter_paco: Optional[int] = Field(
|
||||
default=None, description="AC power rating of the inverter. [W]"
|
||||
)
|
||||
pvforecast3_modules_per_string: Optional[int] = Field(
|
||||
default=None, description="Number of the PV modules of the strings of this plane."
|
||||
)
|
||||
pvforecast3_strings_per_inverter: Optional[int] = Field(
|
||||
default=None, description="Number of the strings of the inverter of this plane."
|
||||
)
|
||||
# Plane 4
|
||||
pvforecast4_surface_tilt: Optional[float] = Field(
|
||||
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
|
||||
)
|
||||
pvforecast4_surface_azimuth: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
)
|
||||
pvforecast4_userhorizon: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
|
||||
)
|
||||
pvforecast4_peakpower: Optional[float] = Field(
|
||||
default=None, description="Nominal power of PV system in kW."
|
||||
)
|
||||
pvforecast4_pvtechchoice: Optional[str] = Field(
|
||||
"crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
|
||||
)
|
||||
pvforecast4_mountingplace: Optional[str] = Field(
|
||||
default="free",
|
||||
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
|
||||
)
|
||||
pvforecast4_loss: Optional[float] = Field(
|
||||
default=14.0, description="Sum of PV system losses in percent"
|
||||
)
|
||||
pvforecast4_trackingtype: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
|
||||
)
|
||||
pvforecast4_optimal_surface_tilt: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast4_optimalangles: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast4_albedo: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Proportion of the light hitting the ground that it reflects back.",
|
||||
)
|
||||
pvforecast4_module_model: Optional[str] = Field(
|
||||
default=None, description="Model of the PV modules of this plane."
|
||||
)
|
||||
pvforecast4_inverter_model: Optional[str] = Field(
|
||||
default=None, description="Model of the inverter of this plane."
|
||||
)
|
||||
pvforecast4_inverter_paco: Optional[int] = Field(
|
||||
default=None, description="AC power rating of the inverter. [W]"
|
||||
)
|
||||
pvforecast4_modules_per_string: Optional[int] = Field(
|
||||
default=None, description="Number of the PV modules of the strings of this plane."
|
||||
)
|
||||
pvforecast4_strings_per_inverter: Optional[int] = Field(
|
||||
default=None, description="Number of the strings of the inverter of this plane."
|
||||
)
|
||||
# Plane 5
|
||||
pvforecast5_surface_tilt: Optional[float] = Field(
|
||||
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
|
||||
)
|
||||
pvforecast5_surface_azimuth: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
)
|
||||
pvforecast5_userhorizon: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.",
|
||||
)
|
||||
pvforecast5_peakpower: Optional[float] = Field(
|
||||
default=None, description="Nominal power of PV system in kW."
|
||||
)
|
||||
pvforecast5_pvtechchoice: Optional[str] = Field(
|
||||
"crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
|
||||
)
|
||||
pvforecast5_mountingplace: Optional[str] = Field(
|
||||
default="free",
|
||||
description="Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
|
||||
)
|
||||
pvforecast5_loss: Optional[float] = Field(
|
||||
default=14.0, description="Sum of PV system losses in percent"
|
||||
)
|
||||
pvforecast5_trackingtype: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.",
|
||||
)
|
||||
pvforecast5_optimal_surface_tilt: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt angle. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast5_optimalangles: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.",
|
||||
)
|
||||
pvforecast5_albedo: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Proportion of the light hitting the ground that it reflects back.",
|
||||
)
|
||||
pvforecast5_module_model: Optional[str] = Field(
|
||||
default=None, description="Model of the PV modules of this plane."
|
||||
)
|
||||
pvforecast5_inverter_model: Optional[str] = Field(
|
||||
default=None, description="Model of the inverter of this plane."
|
||||
)
|
||||
pvforecast5_inverter_paco: Optional[int] = Field(
|
||||
default=None, description="AC power rating of the inverter. [W]"
|
||||
)
|
||||
pvforecast5_modules_per_string: Optional[int] = Field(
|
||||
default=None, description="Number of the PV modules of the strings of this plane."
|
||||
)
|
||||
pvforecast5_strings_per_inverter: Optional[int] = Field(
|
||||
default=None, description="Number of the strings of the inverter of this plane."
|
||||
)
|
||||
|
||||
max_planes: Optional[int] = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Maximum number of planes that can be set",
|
||||
)
|
||||
pvforecast_max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set
|
||||
|
||||
# Validators
|
||||
@field_validator("provider", mode="after")
|
||||
@classmethod
|
||||
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None or value in pvforecast_providers:
|
||||
return value
|
||||
raise ValueError(
|
||||
f"Provider '{value}' is not a valid PV forecast provider: {pvforecast_providers}."
|
||||
)
|
||||
|
||||
## Computed fields
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def planes_peakpower(self) -> List[float]:
|
||||
def pvforecast_planes(self) -> List[str]:
|
||||
"""Compute a list of active planes."""
|
||||
active_planes = []
|
||||
|
||||
# Loop through pvforecast0 to pvforecast4
|
||||
for i in range(self.pvforecast_max_planes):
|
||||
plane = f"pvforecast{i}"
|
||||
tackingtype_attr = f"{plane}_trackingtype"
|
||||
tilt_attr = f"{plane}_surface_tilt"
|
||||
azimuth_attr = f"{plane}_surface_azimuth"
|
||||
|
||||
# Check if either attribute is set and add to active planes
|
||||
if getattr(self, tackingtype_attr, None) == 2:
|
||||
# Tilt angle from horizontal plane is gnored for two-axis tracking.
|
||||
if getattr(self, azimuth_attr, None) is not None:
|
||||
active_planes.append(f"pvforecast{i}")
|
||||
elif getattr(self, tilt_attr, None) and getattr(self, azimuth_attr, None):
|
||||
active_planes.append(f"pvforecast{i}")
|
||||
|
||||
return active_planes
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def pvforecast_planes_peakpower(self) -> List[float]:
|
||||
"""Compute a list of the peak power per active planes."""
|
||||
planes_peakpower = []
|
||||
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
peakpower = plane.peakpower
|
||||
for plane in self.pvforecast_planes:
|
||||
peakpower_attr = f"{plane}_peakpower"
|
||||
peakpower = getattr(self, peakpower_attr, None)
|
||||
if peakpower is None:
|
||||
# TODO calculate peak power from modules/strings
|
||||
planes_peakpower.append(float(5000))
|
||||
@@ -181,13 +402,13 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def planes_azimuth(self) -> List[float]:
|
||||
def pvforecast_planes_azimuth(self) -> List[float]:
|
||||
"""Compute a list of the azimuths per active planes."""
|
||||
planes_azimuth = []
|
||||
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
azimuth = plane.surface_azimuth
|
||||
for plane in self.pvforecast_planes:
|
||||
azimuth_attr = f"{plane}_surface_azimuth"
|
||||
azimuth = getattr(self, azimuth_attr, None)
|
||||
if azimuth is None:
|
||||
# TODO Use default
|
||||
planes_azimuth.append(float(180))
|
||||
@@ -198,13 +419,13 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def planes_tilt(self) -> List[float]:
|
||||
def pvforecast_planes_tilt(self) -> List[float]:
|
||||
"""Compute a list of the tilts per active planes."""
|
||||
planes_tilt = []
|
||||
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
tilt = plane.surface_tilt
|
||||
for plane in self.pvforecast_planes:
|
||||
tilt_attr = f"{plane}_surface_tilt"
|
||||
tilt = getattr(self, tilt_attr, None)
|
||||
if tilt is None:
|
||||
# TODO Use default
|
||||
planes_tilt.append(float(30))
|
||||
@@ -215,13 +436,13 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def planes_userhorizon(self) -> Any:
|
||||
def pvforecast_planes_userhorizon(self) -> Any:
|
||||
"""Compute a list of the user horizon per active planes."""
|
||||
planes_userhorizon = []
|
||||
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
userhorizon = plane.userhorizon
|
||||
for plane in self.pvforecast_planes:
|
||||
userhorizon_attr = f"{plane}_userhorizon"
|
||||
userhorizon = getattr(self, userhorizon_attr, None)
|
||||
if userhorizon is None:
|
||||
# TODO Use default
|
||||
planes_userhorizon.append([float(0), float(0)])
|
||||
@@ -232,13 +453,13 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def planes_inverter_paco(self) -> Any:
|
||||
def pvforecast_planes_inverter_paco(self) -> Any:
|
||||
"""Compute a list of the maximum power rating of the inverter per active planes."""
|
||||
planes_inverter_paco = []
|
||||
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
inverter_paco = plane.inverter_paco
|
||||
for plane in self.pvforecast_planes:
|
||||
inverter_paco_attr = f"{plane}_inverter_paco"
|
||||
inverter_paco = getattr(self, inverter_paco_attr, None)
|
||||
if inverter_paco is None:
|
||||
# TODO Use default - no clipping
|
||||
planes_inverter_paco.append(25000.0)
|
||||
|
@@ -7,11 +7,13 @@ Notes:
|
||||
from abc import abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PVForecastDataRecord(PredictionRecord):
|
||||
"""Represents a pvforecast data record containing various pvforecast attributes at a specific datetime."""
|
||||
@@ -26,18 +28,18 @@ class PVForecastProvider(PredictionProvider):
|
||||
PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
|
||||
Configuration variables:
|
||||
provider (str): Prediction provider for pvforecast.
|
||||
pvforecast_provider (str): Prediction provider for pvforecast.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions (inlcusive), defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range (exclusive),
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The earliest datetime for retaining historical data (inclusive), calculated
|
||||
based on `start_datetime` and `historic_hours`.
|
||||
based on `start_datetime` and `prediction_historic_hours`.
|
||||
"""
|
||||
|
||||
# overload
|
||||
@@ -52,6 +54,6 @@ class PVForecastProvider(PredictionProvider):
|
||||
|
||||
def enabled(self) -> bool:
|
||||
logger.debug(
|
||||
f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.provider}"
|
||||
f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast_provider}"
|
||||
)
|
||||
return self.provider_id() == self.config.pvforecast.provider
|
||||
return self.provider_id() == self.config.pvforecast_provider
|
||||
|
@@ -14,33 +14,21 @@ Classes:
|
||||
Example:
|
||||
# Set up the configuration with necessary fields for URL generation
|
||||
settings_data = {
|
||||
"general": {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": 170,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": 90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
}
|
||||
]
|
||||
}
|
||||
"pvforecast_provider": "Akkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
}
|
||||
|
||||
# Create the config instance from the provided data
|
||||
@@ -59,12 +47,12 @@ Example:
|
||||
print(forecast.report_ac_power_and_measurement())
|
||||
|
||||
Attributes:
|
||||
hours (int): Number of hours into the future to forecast. Default is 48.
|
||||
historic_hours (int): Number of past hours to retain for analysis. Default is 24.
|
||||
prediction_hours (int): Number of hours into the future to forecast. Default is 48.
|
||||
prediction_historic_hours (int): Number of past hours to retain for analysis. Default is 24.
|
||||
latitude (float): Latitude for the forecast location.
|
||||
longitude (float): Longitude for the forecast location.
|
||||
start_datetime (datetime): Start time for the forecast, defaulting to current datetime.
|
||||
end_datetime (datetime): Computed end datetime based on `start_datetime` and `hours`.
|
||||
end_datetime (datetime): Computed end datetime based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime): Computed threshold datetime for retaining historical data.
|
||||
|
||||
Methods:
|
||||
@@ -78,22 +66,24 @@ Methods:
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import Field, ValidationError, computed_field, field_validator
|
||||
from pydantic import Field, ValidationError, computed_field
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.pvforecastabc import (
|
||||
PVForecastDataRecord,
|
||||
PVForecastProvider,
|
||||
)
|
||||
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AkkudoktorForecastHorizon(PydanticBaseModel):
|
||||
altitude: int
|
||||
azimuthFrom: float
|
||||
azimuthTo: float
|
||||
azimuthFrom: int
|
||||
azimuthTo: int
|
||||
|
||||
|
||||
class AkkudoktorForecastMeta(PydanticBaseModel):
|
||||
@@ -112,30 +102,6 @@ class AkkudoktorForecastMeta(PydanticBaseModel):
|
||||
horizont: List[List[AkkudoktorForecastHorizon]]
|
||||
horizontString: List[str]
|
||||
|
||||
@field_validator("power", "azimuth", "tilt", "powerInverter", mode="before")
|
||||
@classmethod
|
||||
def ensure_list(cls, v: Any) -> List[int]:
|
||||
return v if isinstance(v, list) else [v]
|
||||
|
||||
@field_validator("horizont", mode="before")
|
||||
@classmethod
|
||||
def normalize_horizont(cls, v: Any) -> List[List[AkkudoktorForecastHorizon]]:
|
||||
if isinstance(v, list):
|
||||
# Case: flat list of dicts
|
||||
if v and isinstance(v[0], dict):
|
||||
return [v]
|
||||
# Already in correct nested form
|
||||
if v and isinstance(v[0], list):
|
||||
return v
|
||||
return v
|
||||
|
||||
@field_validator("horizontString", mode="before")
|
||||
@classmethod
|
||||
def parse_horizont_string(cls, v: Any) -> List[str]:
|
||||
if isinstance(v, str):
|
||||
return [s.strip() for s in v.split(",")]
|
||||
return v
|
||||
|
||||
|
||||
class AkkudoktorForecastValue(PydanticBaseModel):
|
||||
datetime: str
|
||||
@@ -193,13 +159,13 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
prediction_hours (int, optional): Number of hours in the future for the forecast.
|
||||
prediction_historic_hours (int, optional): Number of past hours for retaining data.
|
||||
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
|
||||
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`.
|
||||
|
||||
Methods:
|
||||
provider_id(): Returns a unique identifier for the provider.
|
||||
@@ -237,24 +203,19 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
"""Build akkudoktor.net API request URL."""
|
||||
base_url = "https://api.akkudoktor.net/forecast"
|
||||
query_params = [
|
||||
f"lat={self.config.general.latitude}",
|
||||
f"lon={self.config.general.longitude}",
|
||||
f"lat={self.config.latitude}",
|
||||
f"lon={self.config.longitude}",
|
||||
]
|
||||
|
||||
for i in range(len(self.config.pvforecast.planes)):
|
||||
query_params.append(f"power={int(self.config.pvforecast.planes_peakpower[i] * 1000)}")
|
||||
# EOS orientation of of pv modules in azimuth in degree:
|
||||
# north=0, east=90, south=180, west=270
|
||||
# Akkudoktor orientation of pv modules in azimuth in degree:
|
||||
# north=+-180, east=-90, south=0, west=90
|
||||
azimuth_akkudoktor = int(self.config.pvforecast.planes_azimuth[i]) - 180
|
||||
query_params.append(f"azimuth={azimuth_akkudoktor}")
|
||||
query_params.append(f"tilt={int(self.config.pvforecast.planes_tilt[i])}")
|
||||
for i in range(len(self.config.pvforecast_planes)):
|
||||
query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}")
|
||||
query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}")
|
||||
query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}")
|
||||
query_params.append(
|
||||
f"powerInverter={int(self.config.pvforecast.planes_inverter_paco[i])}"
|
||||
f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}"
|
||||
)
|
||||
horizon_values = ",".join(
|
||||
str(round(h)) for h in self.config.pvforecast.planes_userhorizon[i]
|
||||
str(int(h)) for h in self.config.pvforecast_planes_userhorizon[i]
|
||||
)
|
||||
query_params.append(f"horizont={horizon_values}")
|
||||
|
||||
@@ -265,7 +226,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
"cellCoEff=-0.36",
|
||||
"inverterEfficiency=0.8",
|
||||
"albedo=0.25",
|
||||
f"timezone={self.config.general.timezone}",
|
||||
f"timezone={self.config.timezone}",
|
||||
"hourly=relativehumidity_2m%2Cwindspeed_10m",
|
||||
]
|
||||
)
|
||||
@@ -289,12 +250,12 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
Raises:
|
||||
ValueError: If the API response does not include expected `meta` data.
|
||||
"""
|
||||
response = requests.get(self._url(), timeout=10)
|
||||
response = requests.get(self._url())
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
logger.debug(f"Response from {self._url()}: {response}")
|
||||
akkudoktor_data = self._validate_data(response.content)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
return akkudoktor_data
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
@@ -304,7 +265,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
`PVForecastAkkudoktorDataRecord`.
|
||||
"""
|
||||
# Assure we have something to request PV power for.
|
||||
if not self.config.pvforecast.planes:
|
||||
if not self.config.pvforecast_planes:
|
||||
# No planes for PV
|
||||
error_msg = "Requested PV forecast, but no planes configured."
|
||||
logger.error(f"Configuration error: {error_msg}")
|
||||
@@ -314,29 +275,28 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
|
||||
|
||||
# Timezone of the PV system
|
||||
if self.config.general.timezone != akkudoktor_data.meta.timezone:
|
||||
error_msg = f"Configured timezone '{self.config.general.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
|
||||
if self.config.timezone != akkudoktor_data.meta.timezone:
|
||||
error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
|
||||
logger.error(f"Akkudoktor schema change: {error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
if len(akkudoktor_data.values[0]) < self.config.prediction.hours:
|
||||
if len(akkudoktor_data.values[0]) < self.config.prediction_hours:
|
||||
# Expect one value set per prediction hour
|
||||
error_msg = (
|
||||
f"The forecast must cover at least {self.config.prediction.hours} hours, "
|
||||
f"The forecast must cover at least {self.config.prediction_hours} hours, "
|
||||
f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data."
|
||||
)
|
||||
logger.error(f"Akkudoktor schema change: {error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
assert self.start_datetime # mypy fix
|
||||
|
||||
# Iterate over forecast data points
|
||||
for forecast_values in zip(*akkudoktor_data.values):
|
||||
original_datetime = forecast_values[0].datetime
|
||||
dt = to_datetime(original_datetime, in_timezone=self.config.general.timezone)
|
||||
dt = to_datetime(original_datetime, in_timezone=self.config.timezone)
|
||||
|
||||
# Skip outdated forecast data
|
||||
if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
|
||||
@@ -354,9 +314,9 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
|
||||
self.update_value(dt, data)
|
||||
|
||||
if len(self) < self.config.prediction.hours:
|
||||
if len(self) < self.config.prediction_hours:
|
||||
raise ValueError(
|
||||
f"The forecast must cover at least {self.config.prediction.hours} hours, "
|
||||
f"The forecast must cover at least {self.config.prediction_hours} hours, "
|
||||
f"but only {len(self)} hours starting from {self.start_datetime} "
|
||||
f"were predicted."
|
||||
)
|
||||
@@ -405,47 +365,31 @@ if __name__ == "__main__":
|
||||
"""
|
||||
# Set up the configuration with necessary fields for URL generation
|
||||
settings_data = {
|
||||
"general": {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": 170,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": 90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": 140,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 185,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
},
|
||||
],
|
||||
},
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
}
|
||||
|
||||
# Initialize the forecast object with the generated configuration
|
||||
|
@@ -9,33 +9,34 @@ format, enabling consistent access to forecasted and historical pvforecast attri
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
|
||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PVForecastImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for pvforecast data import from file or JSON string."""
|
||||
|
||||
import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None,
|
||||
description="Path to the file to import PV forecast data from.",
|
||||
examples=[None, "/path/to/pvforecast.json"],
|
||||
pvforecastimport_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None, description="Path to the file to import PV forecast data from."
|
||||
)
|
||||
|
||||
import_json: Optional[str] = Field(
|
||||
pvforecastimport_json: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JSON string, dictionary of PV forecast value lists.",
|
||||
examples=['{"pvforecast_ac_power": [0, 8.05, 352.91]}'],
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("import_file_path", mode="after")
|
||||
@field_validator("pvforecastimport_file_path", mode="after")
|
||||
@classmethod
|
||||
def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
|
||||
def validate_pvforecastimport_file_path(
|
||||
cls, value: Optional[Union[str, Path]]
|
||||
) -> Optional[Path]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
@@ -61,16 +62,7 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
|
||||
return "PVForecastImport"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
if self.config.pvforecast.provider_settings is None:
|
||||
logger.debug(f"{self.provider_id()} data update without provider settings.")
|
||||
return
|
||||
if self.config.pvforecast.provider_settings.import_file_path is not None:
|
||||
self.import_from_file(
|
||||
self.config.pvforecast.provider_settings.import_file_path,
|
||||
key_prefix="pvforecast",
|
||||
)
|
||||
if self.config.pvforecast.provider_settings.import_json is not None:
|
||||
self.import_from_json(
|
||||
self.config.pvforecast.provider_settings.import_json,
|
||||
key_prefix="pvforecast",
|
||||
)
|
||||
if self.config.pvforecastimport_file_path is not None:
|
||||
self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast")
|
||||
if self.config.pvforecastimport_json is not None:
|
||||
self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast")
|
||||
|
@@ -1,110 +0,0 @@
|
||||
"""Retrieves pvforecast data from VRM API."""
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import Field, ValidationError
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
class VrmForecastRecords(PydanticBaseModel):
|
||||
vrm_consumption_fc: list[tuple[int, float]]
|
||||
solar_yield_forecast: list[tuple[int, float]]
|
||||
|
||||
|
||||
class VrmForecastResponse(PydanticBaseModel):
|
||||
success: bool
|
||||
records: VrmForecastRecords
|
||||
totals: dict
|
||||
|
||||
|
||||
class PVforecastVrmCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for VRM API."""
|
||||
|
||||
pvforecast_vrm_token: str = Field(
|
||||
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
|
||||
)
|
||||
pvforecast_vrm_idsite: int = Field(
|
||||
default=12345, description="VRM-Installation-ID", examples=[12345]
|
||||
)
|
||||
|
||||
|
||||
class PVForecastVrm(PVForecastProvider):
|
||||
"""Fetch and process PV forecast data from VRM API."""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the PV-Forecast-Provider."""
|
||||
return "PVForecastVrm"
|
||||
|
||||
@classmethod
|
||||
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
|
||||
"""Validate the VRM forecast response data against the expected schema."""
|
||||
try:
|
||||
return VrmForecastResponse.model_validate_json(json_str)
|
||||
except ValidationError as e:
|
||||
error_msg = "\n".join(
|
||||
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
|
||||
f"Error: {err['msg']}\nType: {err['type']}"
|
||||
for err in e.errors()
|
||||
)
|
||||
logger.error(f"VRM-API schema change:\n{error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
|
||||
"""Fetch forecast data from Victron VRM API."""
|
||||
source = "https://vrmapi.victronenergy.com/v2/installations"
|
||||
id_site = self.config.pvforecast.provider_settings.pvforecast_vrm_idsite
|
||||
api_token = self.config.pvforecast.provider_settings.pvforecast_vrm_token
|
||||
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
|
||||
url = f"{source}/{id_site}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
|
||||
logger.debug(f"Requesting VRM forecast: {url}")
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch pvforecast: {e}")
|
||||
raise RuntimeError("Failed to fetch pvforecast from VRM API") from e
|
||||
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return self._validate_data(response.content)
|
||||
|
||||
def _ts_to_datetime(self, timestamp: int) -> DateTime:
|
||||
"""Convert UNIX ms timestamp to timezone-aware datetime."""
|
||||
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Update forecast data in the PVForecastDataRecord format."""
|
||||
start_date = self.start_datetime.start_of("day")
|
||||
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
|
||||
start_ts = int(start_date.timestamp())
|
||||
end_ts = int(end_date.timestamp())
|
||||
|
||||
logger.info(f"Updating PV forecast from VRM: {start_date} to {end_date}")
|
||||
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
|
||||
|
||||
pv_forecast = []
|
||||
for timestamp, value in vrm_forecast_data.records.solar_yield_forecast:
|
||||
date = self._ts_to_datetime(timestamp)
|
||||
dc_power = round(value, 2)
|
||||
ac_power = round(dc_power * 0.96, 2)
|
||||
self.update_value(
|
||||
date, {"pvforecast_dc_power": dc_power, "pvforecast_ac_power": ac_power}
|
||||
)
|
||||
pv_forecast.append((date, dc_power))
|
||||
|
||||
logger.debug(f"Updated pvforecast_dc_power with {len(pv_forecast)} entries.")
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
pv = PVForecastVrm()
|
||||
pv._update_data()
|
@@ -2,42 +2,12 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
from akkudoktoreos.prediction.weatherabc import WeatherProvider
|
||||
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
||||
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
# Valid weather providers
|
||||
weather_providers = [
|
||||
provider.provider_id()
|
||||
for provider in prediction_eos.providers
|
||||
if isinstance(provider, WeatherProvider)
|
||||
]
|
||||
|
||||
|
||||
class WeatherCommonSettings(SettingsBaseModel):
|
||||
"""Weather Forecast Configuration."""
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Weather provider id of provider to be used.",
|
||||
examples=["WeatherImport"],
|
||||
)
|
||||
|
||||
provider_settings: Optional[WeatherImportCommonSettings] = Field(
|
||||
default=None, description="Provider settings", examples=[None]
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("provider", mode="after")
|
||||
@classmethod
|
||||
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None or value in weather_providers:
|
||||
return value
|
||||
raise ValueError(
|
||||
f"Provider '{value}' is not a valid weather provider: {weather_providers}."
|
||||
weather_provider: Optional[str] = Field(
|
||||
default=None, description="Weather provider id of provider to be used."
|
||||
)
|
||||
|
@@ -14,8 +14,11 @@ import pandas as pd
|
||||
import pvlib
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WeatherDataRecord(PredictionRecord):
|
||||
"""Represents a weather data record containing various weather attributes at a specific datetime.
|
||||
@@ -98,18 +101,18 @@ class WeatherProvider(PredictionProvider):
|
||||
WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
|
||||
Configuration variables:
|
||||
provider (str): Prediction provider for weather.
|
||||
weather_provider (str): Prediction provider for weather.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
|
||||
based on `start_datetime` and `historic_hours`.
|
||||
based on `start_datetime` and `prediction_historic_hours`.
|
||||
"""
|
||||
|
||||
# overload
|
||||
@@ -123,7 +126,7 @@ class WeatherProvider(PredictionProvider):
|
||||
return "WeatherProvider"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self.provider_id() == self.config.weather.provider
|
||||
return self.provider_id() == self.config.weather_provider
|
||||
|
||||
@classmethod
|
||||
def estimate_irradiance_from_cloud_cover(
|
||||
|
@@ -7,21 +7,23 @@ format, enabling consistent access to forecasted and historical weather attribut
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pvlib
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
WheaterDataBrightSkyMapping: List[Tuple[str, Optional[str], Optional[Union[str, float]]]] = [
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
WheaterDataBrightSkyMapping: List[Tuple[str, Optional[str], Optional[float]]] = [
|
||||
# brightsky_key, description, corr_factor
|
||||
("timestamp", "DateTime", "to datetime in timezone"),
|
||||
("timestamp", "DateTime", None),
|
||||
("precipitation", "Precipitation Amount (mm)", 1),
|
||||
("pressure_msl", "Pressure (mb)", 1),
|
||||
("sunshine", None, None),
|
||||
@@ -60,13 +62,13 @@ class WeatherBrightSky(WeatherProvider):
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
prediction_hours (int, optional): Number of hours in the future for the forecast.
|
||||
prediction_historic_hours (int, optional): Number of past hours for retaining data.
|
||||
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
|
||||
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`.
|
||||
|
||||
Methods:
|
||||
provider_id(): Returns a unique identifier for the provider.
|
||||
@@ -94,11 +96,10 @@ class WeatherBrightSky(WeatherProvider):
|
||||
ValueError: If the API response does not include expected `weather` data.
|
||||
"""
|
||||
source = "https://api.brightsky.dev"
|
||||
date = to_datetime(self.start_datetime, as_string=True)
|
||||
last_date = to_datetime(self.end_datetime, as_string=True)
|
||||
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD")
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
response = requests.get(
|
||||
f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}",
|
||||
timeout=10,
|
||||
f"{source}/weather?lat={self.config.latitude}&lon={self.config.longitude}&date={date}&last_date={last_date}&tz={self.config.timezone}"
|
||||
)
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
logger.debug(f"Response from {source}: {response}")
|
||||
@@ -108,7 +109,7 @@ class WeatherBrightSky(WeatherProvider):
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
return brightsky_data
|
||||
|
||||
def _description_to_series(self, description: str) -> pd.Series:
|
||||
@@ -132,8 +133,7 @@ class WeatherBrightSky(WeatherProvider):
|
||||
error_msg = f"No WeatherDataRecord key for '{description}'"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
series = self.key_to_series(key)
|
||||
return series
|
||||
return self.key_to_series(key)
|
||||
|
||||
def _description_from_series(self, description: str, data: pd.Series) -> None:
|
||||
"""Update a weather data with a pandas Series based on its description.
|
||||
@@ -170,7 +170,7 @@ class WeatherBrightSky(WeatherProvider):
|
||||
brightsky_data = self._request_forecast(force_update=force_update) # type: ignore
|
||||
|
||||
# Get key mapping from description
|
||||
brightsky_key_mapping: Dict[str, Tuple[Optional[str], Optional[Union[str, float]]]] = {}
|
||||
brightsky_key_mapping: Dict[str, Tuple[Optional[str], Optional[float]]] = {}
|
||||
for brightsky_key, description, corr_factor in WheaterDataBrightSkyMapping:
|
||||
if description is None:
|
||||
brightsky_key_mapping[brightsky_key] = (None, None)
|
||||
@@ -192,9 +192,6 @@ class WeatherBrightSky(WeatherProvider):
|
||||
value = brightsky_record[brightsky_key]
|
||||
corr_factor = item[1]
|
||||
if value and corr_factor:
|
||||
if corr_factor == "to datetime in timezone":
|
||||
value = to_datetime(value, in_timezone=self.config.general.timezone)
|
||||
else:
|
||||
value = value * corr_factor
|
||||
setattr(weather_record, key, value)
|
||||
self.insert_by_datetime(weather_record)
|
||||
@@ -203,7 +200,7 @@ class WeatherBrightSky(WeatherProvider):
|
||||
description = "Total Clouds (% Sky Obscured)"
|
||||
cloud_cover = self._description_to_series(description)
|
||||
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
|
||||
self.config.general.latitude, self.config.general.longitude, cloud_cover
|
||||
self.config.latitude, self.config.longitude, cloud_cover
|
||||
)
|
||||
|
||||
description = "Global Horizontal Irradiance (W/m2)"
|
||||
@@ -219,40 +216,14 @@ class WeatherBrightSky(WeatherProvider):
|
||||
self._description_from_series(description, dhi)
|
||||
|
||||
# Add Preciptable Water (PWAT) with a PVLib method.
|
||||
key = WeatherDataRecord.key_from_description("Temperature (°C)")
|
||||
assert key # noqa: S101
|
||||
temperature = self.key_to_array(
|
||||
key=key,
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
if any(x is None or isinstance(x, float) and np.isnan(x) for x in temperature):
|
||||
# We can not calculate PWAT
|
||||
debug_msg = f"Innvalid temperature '{temperature}'"
|
||||
logger.debug(debug_msg)
|
||||
return
|
||||
key = WeatherDataRecord.key_from_description("Relative Humidity (%)")
|
||||
assert key # noqa: S101
|
||||
humidity = self.key_to_array(
|
||||
key=key,
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
if any(x is None or isinstance(x, float) and np.isnan(x) for x in humidity):
|
||||
# We can not calculate PWAT
|
||||
debug_msg = f"Innvalid humidity '{humidity}'"
|
||||
logger.debug(debug_msg)
|
||||
return
|
||||
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
|
||||
description = "Temperature (°C)"
|
||||
temperature = self._description_to_series(description)
|
||||
|
||||
description = "Relative Humidity (%)"
|
||||
humidity = self._description_to_series(description)
|
||||
|
||||
pwat = pd.Series(
|
||||
data=data,
|
||||
index=pd.DatetimeIndex(
|
||||
pd.date_range(
|
||||
start=self.start_datetime, end=self.end_datetime, freq="1h", inclusive="left"
|
||||
)
|
||||
),
|
||||
data=pvlib.atmosphere.gueymard94_pw(temperature, humidity), index=temperature.index
|
||||
)
|
||||
description = "Preciptable Water (cm)"
|
||||
self._description_from_series(description, pwat)
|
||||
|
@@ -18,12 +18,15 @@ from typing import Dict, List, Optional, Tuple
|
||||
import pandas as pd
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
|
||||
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_timezone
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
WheaterDataClearOutsideMapping: List[Tuple[str, Optional[str], Optional[float]]] = [
|
||||
# clearoutside_key, description, corr_factor
|
||||
("DateTime", "DateTime", None),
|
||||
@@ -65,15 +68,15 @@ class WeatherClearOutside(WeatherProvider):
|
||||
WeatherClearOutside is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
|
||||
based on `start_datetime` and `historic_hours`.
|
||||
based on `start_datetime` and `prediction_historic_hours`.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -85,16 +88,16 @@ class WeatherClearOutside(WeatherProvider):
|
||||
"""Requests weather forecast from ClearOutside.
|
||||
|
||||
Returns:
|
||||
response: Weather forecast request response from ClearOutside.
|
||||
response: Weather forecast request reponse from ClearOutside.
|
||||
"""
|
||||
source = "https://clearoutside.com/forecast"
|
||||
latitude = round(self.config.general.latitude, 2)
|
||||
longitude = round(self.config.general.longitude, 2)
|
||||
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true", timeout=10)
|
||||
latitude = round(self.config.latitude, 2)
|
||||
longitude = round(self.config.longitude, 2)
|
||||
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
logger.debug(f"Response from {source}: {response}")
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
return response
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = None) -> None:
|
||||
@@ -304,7 +307,7 @@ class WeatherClearOutside(WeatherProvider):
|
||||
data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"]
|
||||
)
|
||||
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
|
||||
self.config.general.latitude, self.config.general.longitude, cloud_cover
|
||||
self.config.latitude, self.config.longitude, cloud_cover
|
||||
)
|
||||
|
||||
# Add GHI, DNI, DHI to clearout data
|
||||
|
@@ -9,33 +9,31 @@ format, enabling consistent access to forecasted and historical weather attribut
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
|
||||
from akkudoktoreos.prediction.weatherabc import WeatherProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WeatherImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for weather data import from file or JSON string."""
|
||||
|
||||
import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None,
|
||||
description="Path to the file to import weather data from.",
|
||||
examples=[None, "/path/to/weather_data.json"],
|
||||
weatherimport_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None, description="Path to the file to import weather data from."
|
||||
)
|
||||
|
||||
import_json: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JSON string, dictionary of weather forecast value lists.",
|
||||
examples=['{"weather_temp_air": [18.3, 17.8, 16.9]}'],
|
||||
weatherimport_json: Optional[str] = Field(
|
||||
default=None, description="JSON string, dictionary of weather forecast value lists."
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("import_file_path", mode="after")
|
||||
@field_validator("weatherimport_file_path", mode="after")
|
||||
@classmethod
|
||||
def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
|
||||
def validate_weatherimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
@@ -61,14 +59,7 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
|
||||
return "WeatherImport"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
if self.config.weather.provider_settings is None:
|
||||
logger.debug(f"{self.provider_id()} data update without provider settings.")
|
||||
return
|
||||
if self.config.weather.provider_settings.import_file_path:
|
||||
self.import_from_file(
|
||||
self.config.weather.provider_settings.import_file_path, key_prefix="weather"
|
||||
)
|
||||
if self.config.weather.provider_settings.import_json:
|
||||
self.import_from_json(
|
||||
self.config.weather.provider_settings.import_json, key_prefix="weather"
|
||||
)
|
||||
if self.config.weatherimport_file_path is not None:
|
||||
self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather")
|
||||
if self.config.weatherimport_json is not None:
|
||||
self.import_from_json(self.config.weatherimport_json, key_prefix="weather")
|
||||
|
@@ -1,297 +0,0 @@
|
||||
"""Admin UI components for EOS Dashboard.
|
||||
|
||||
This module provides functions to generate administrative UI components
|
||||
for the EOS dashboard.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from fasthtml.common import Select
|
||||
from loguru import logger
|
||||
from monsterui.foundations import stringify
|
||||
from monsterui.franken import ( # Select, TODO: Select from FrankenUI does not work - using Select from FastHTML instead
|
||||
H3,
|
||||
Button,
|
||||
ButtonT,
|
||||
Card,
|
||||
Details,
|
||||
Div,
|
||||
DivHStacked,
|
||||
DividerLine,
|
||||
Grid,
|
||||
Input,
|
||||
Options,
|
||||
P,
|
||||
Summary,
|
||||
UkIcon,
|
||||
)
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from akkudoktoreos.server.dash.components import Error, Success
|
||||
from akkudoktoreos.server.dash.configuration import get_nested_value
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
# Directory to export files to, or to import files from
|
||||
export_import_directory = Path(user_config_dir("net.akkudoktor.eosdash", "akkudoktor"))
|
||||
|
||||
|
||||
def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
|
||||
"""Creates a styled button for administrative actions.
|
||||
|
||||
Args:
|
||||
*c (Any): Positional arguments representing the button's content.
|
||||
cls (Optional[Union[str, tuple]]): Additional CSS classes for styling. Defaults to None.
|
||||
**kwargs (Any): Additional keyword arguments passed to the `Button`.
|
||||
|
||||
Returns:
|
||||
Button: A styled `Button` component for admin actions.
|
||||
"""
|
||||
new_cls = f"{ButtonT.primary}"
|
||||
if cls:
|
||||
new_cls += f" {stringify(cls)}"
|
||||
kwargs["cls"] = new_cls
|
||||
return Button(*c, submit=False, **kwargs)
|
||||
|
||||
|
||||
def AdminConfig(
|
||||
eos_host: str, eos_port: Union[str, int], data: Optional[dict], config: Optional[dict[str, Any]]
|
||||
) -> tuple[str, Union[Card, list[Card]]]:
|
||||
"""Creates a configuration management card with save-to-file functionality.
|
||||
|
||||
Args:
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
data (Optional[dict]): Incoming data containing action and category for processing.
|
||||
|
||||
Returns:
|
||||
tuple[str, Union[Card, list[Card]]]: A tuple containing the configuration category label and the `Card` UI component.
|
||||
"""
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
eos_hostname = "EOS server"
|
||||
eosdash_hostname = "EOSdash server"
|
||||
|
||||
category = "configuration"
|
||||
# save config file
|
||||
status = (None,)
|
||||
config_file_path = "<unknown>"
|
||||
try:
|
||||
if config:
|
||||
config_file_path = get_nested_value(config, ["general", "config_file_path"])
|
||||
except Exception as e:
|
||||
logger.debug(f"general.config_file_path: {e}")
|
||||
# export config file
|
||||
export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss")
|
||||
export_to_file_status = (None,)
|
||||
# import config file
|
||||
import_from_file_status = (None,)
|
||||
|
||||
if data and data.get("category", None) == category:
|
||||
# This data is for us
|
||||
if data["action"] == "save_to_file":
|
||||
# Safe current configuration to file
|
||||
try:
|
||||
result = requests.put(f"{server}/v1/config/file", timeout=10)
|
||||
result.raise_for_status()
|
||||
config_file_path = result.json()["general"]["config_file_path"]
|
||||
status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
status = Error(
|
||||
f"Can not save actual config to file on '{eos_hostname}': {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
status = Error(f"Can not save actual config to file on '{eos_hostname}': {e}")
|
||||
elif data["action"] == "export_to_file":
|
||||
# Export current configuration to file
|
||||
export_to_file_tag = data.get("export_to_file_tag", export_to_file_next_tag)
|
||||
export_to_file_path = export_import_directory.joinpath(
|
||||
f"eos_config_{export_to_file_tag}.json"
|
||||
)
|
||||
try:
|
||||
if not config:
|
||||
raise ValueError(f"No config from '{eos_hostname}'")
|
||||
export_to_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with export_to_file_path.open("w", encoding="utf-8", newline="\n") as fd:
|
||||
json.dump(config, fd, indent=4, sort_keys=True)
|
||||
export_to_file_status = Success(
|
||||
f"Exported to '{export_to_file_path}' on '{eosdash_hostname}'"
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
export_to_file_status = Error(
|
||||
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
export_to_file_status = Error(
|
||||
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}"
|
||||
)
|
||||
elif data["action"] == "import_from_file":
|
||||
import_file_name = data.get("import_file_name", None)
|
||||
import_from_file_pathes = list(
|
||||
export_import_directory.glob("*.json")
|
||||
) # expand generator object
|
||||
import_file_path = None
|
||||
for f in import_from_file_pathes:
|
||||
if f.name == import_file_name:
|
||||
import_file_path = f
|
||||
if import_file_path:
|
||||
try:
|
||||
with import_file_path.open("r", encoding="utf-8", newline=None) as fd:
|
||||
import_config = json.load(fd)
|
||||
result = requests.put(f"{server}/v1/config", json=import_config, timeout=10)
|
||||
result.raise_for_status()
|
||||
import_from_file_status = Success(
|
||||
f"Config imported from '{import_file_path}' on '{eosdash_hostname}'"
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}"
|
||||
)
|
||||
else:
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}', not found in '{export_import_directory}' on '{eosdash_hostname}'"
|
||||
)
|
||||
|
||||
# Update for display, in case we added a new file before
|
||||
import_from_file_names = [f.name for f in list(export_import_directory.glob("*.json"))]
|
||||
|
||||
return (
|
||||
category,
|
||||
[
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Save to file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='{"category": "configuration", "action": "save_to_file"}',
|
||||
),
|
||||
P(f"'{config_file_path}' on '{eos_hostname}'"),
|
||||
),
|
||||
status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(f"Safe actual configuration to '{config_file_path}' on '{eos_hostname}'."),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Export to file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{"category": "configuration", "action": "export_to_file", "export_to_file_tag": document.querySelector("[name=\'chosen_export_file_tag\']").value }',
|
||||
),
|
||||
P("'eos_config_"),
|
||||
Input(
|
||||
id="export_file_tag",
|
||||
name="chosen_export_file_tag",
|
||||
value=export_to_file_next_tag,
|
||||
),
|
||||
P(".json'"),
|
||||
),
|
||||
export_to_file_status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(
|
||||
f"Export actual configuration to 'eos_config_{export_to_file_next_tag}.json' on '{eosdash_hostname}'."
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Import from file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "configuration", "action": "import_from_file", "import_file_name": document.querySelector("[name=\'selected_import_file_name\']").value }',
|
||||
),
|
||||
Select(
|
||||
*Options(*import_from_file_names),
|
||||
id="import_file_name",
|
||||
name="selected_import_file_name", # Name of hidden input field with selected value
|
||||
placeholder="Select file",
|
||||
),
|
||||
),
|
||||
import_from_file_status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(f"Import configuration from config file on '{eosdash_hostname}'."),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) -> Div:
|
||||
"""Generates the administrative dashboard layout.
|
||||
|
||||
This includes configuration management and other administrative tools.
|
||||
|
||||
Args:
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
data (Optional[dict], optional): Incoming data to trigger admin actions. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Div: A `Div` component containing the assembled admin interface.
|
||||
"""
|
||||
# Get current configuration from server
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config", timeout=10)
|
||||
result.raise_for_status()
|
||||
config = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
config = {}
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
except Exception as e:
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
|
||||
rows = []
|
||||
last_category = ""
|
||||
for category, admin in [
|
||||
AdminConfig(eos_host, eos_port, data, config),
|
||||
]:
|
||||
if category != last_category:
|
||||
rows.append(H3(category))
|
||||
rows.append(DividerLine())
|
||||
last_category = category
|
||||
if isinstance(admin, list):
|
||||
for card in admin:
|
||||
rows.append(card)
|
||||
else:
|
||||
rows.append(admin)
|
||||
|
||||
return Div(*rows, cls="space-y-4")
|
Before Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 112 KiB |
Before Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 724 B |
Before Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
Before Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 12 KiB |
@@ -1,38 +0,0 @@
|
||||
# Module taken from https://github.com/koaning/fh-altair
|
||||
# MIT license
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from bokeh.embed import components
|
||||
from bokeh.models import Plot
|
||||
from monsterui.franken import H4, Card, NotStr, Script
|
||||
|
||||
BokehJS = [
|
||||
Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.7.0.min.js", crossorigin="anonymous"),
|
||||
Script(
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.7.0.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
Script(
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.7.0.min.js", crossorigin="anonymous"
|
||||
),
|
||||
Script(
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.7.0.min.js", crossorigin="anonymous"
|
||||
),
|
||||
Script(
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.7.0.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def Bokeh(plot: Plot, header: Optional[str] = None) -> Card:
|
||||
"""Converts an Bokeh plot to a FastHTML FT component."""
|
||||
script, div = components(plot)
|
||||
if header:
|
||||
header = H4(header, cls="mt-2")
|
||||
return Card(
|
||||
NotStr(div),
|
||||
NotStr(script),
|
||||
header=header,
|
||||
)
|
@@ -1,317 +0,0 @@
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from fasthtml.common import H1, Div, Li
|
||||
from monsterui.daisy import (
|
||||
Alert,
|
||||
AlertT,
|
||||
)
|
||||
from monsterui.foundations import stringify
|
||||
from monsterui.franken import (
|
||||
H3,
|
||||
Button,
|
||||
ButtonT,
|
||||
Card,
|
||||
Container,
|
||||
ContainerT,
|
||||
Details,
|
||||
DivLAligned,
|
||||
DivRAligned,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
P,
|
||||
Summary,
|
||||
TabContainer,
|
||||
UkIcon,
|
||||
)
|
||||
|
||||
scrollbar_viewport_styles = (
|
||||
"scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;"
|
||||
)
|
||||
|
||||
scrollbar_cls = "flex touch-none select-none transition-colors p-[1px]"
|
||||
|
||||
|
||||
def ScrollArea(
|
||||
*c: Any, cls: Optional[Union[str, tuple]] = None, orientation: str = "vertical", **kwargs: Any
|
||||
) -> Div:
|
||||
"""Creates a styled scroll area.
|
||||
|
||||
Args:
|
||||
orientation (str): The orientation of the scroll area. Defaults to vertical.
|
||||
"""
|
||||
new_cls = "relative overflow-hidden"
|
||||
if cls:
|
||||
new_cls += f" {stringify(cls)}"
|
||||
kwargs["cls"] = new_cls
|
||||
|
||||
content = Div(
|
||||
Div(*c, style="min-width:100%;display:table;"),
|
||||
style=f"overflow: {'hidden scroll' if orientation == 'vertical' else 'scroll'}; {scrollbar_viewport_styles}",
|
||||
cls="w-full h-full rounded-[inherit]",
|
||||
data_ref="viewport",
|
||||
)
|
||||
|
||||
scrollbar = Div(
|
||||
Div(cls="bg-border rounded-full hidden relative flex-1", data_ref="thumb"),
|
||||
cls=f"{scrollbar_cls} flex-col h-2.5 w-full border-t border-t-transparent"
|
||||
if orientation == "horizontal"
|
||||
else f"{scrollbar_cls} w-2.5 h-full border-l border-l-transparent",
|
||||
data_ref="scrollbar",
|
||||
style=f"position: absolute;{'right:0; top:0;' if orientation == 'vertical' else 'bottom:0; left:0;'}",
|
||||
)
|
||||
|
||||
return Div(
|
||||
content,
|
||||
scrollbar,
|
||||
role="region",
|
||||
tabindex="0",
|
||||
data_orientation=orientation,
|
||||
data_ref_scrollarea=True,
|
||||
aria_label="Scrollable content",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def Success(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("check"),
|
||||
P(*c),
|
||||
),
|
||||
cls=AlertT.success,
|
||||
)
|
||||
|
||||
|
||||
def Error(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("triangle-alert"),
|
||||
P(*c),
|
||||
),
|
||||
cls=AlertT.error,
|
||||
)
|
||||
|
||||
|
||||
def ConfigCard(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
read_only: str,
|
||||
value: str,
|
||||
default: str,
|
||||
description: str,
|
||||
deprecated: Optional[Union[str, bool]],
|
||||
update_error: Optional[str],
|
||||
update_value: Optional[str],
|
||||
update_open: Optional[bool],
|
||||
) -> Card:
|
||||
"""Creates a styled configuration card for displaying configuration details.
|
||||
|
||||
This function generates a configuration card that is displayed in the UI with
|
||||
various sections such as configuration name, type, description, default value,
|
||||
current value, and error details. It supports both read-only and editable modes.
|
||||
|
||||
Args:
|
||||
config_name (str): The name of the configuration.
|
||||
config_type (str): The type of the configuration.
|
||||
read_only (str): Indicates if the configuration is read-only ("rw" for read-write,
|
||||
any other value indicates read-only).
|
||||
value (str): The current value of the configuration.
|
||||
default (str): The default value of the configuration.
|
||||
description (str): A description of the configuration.
|
||||
deprecated (Optional[Union[str, bool]]): The deprecated marker of the configuration.
|
||||
update_error (Optional[str]): The error message, if any, during the update process.
|
||||
update_value (Optional[str]): The value to be updated, if different from the current value.
|
||||
update_open (Optional[bool]): A flag indicating whether the update section of the card
|
||||
should be initially expanded.
|
||||
|
||||
Returns:
|
||||
Card: A styled Card component containing the configuration details.
|
||||
"""
|
||||
config_id = config_name.replace(".", "-")
|
||||
if not update_value:
|
||||
update_value = value
|
||||
if not update_open:
|
||||
update_open = False
|
||||
if deprecated:
|
||||
if isinstance(deprecated, bool):
|
||||
deprecated = "Deprecated"
|
||||
return Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
P(config_name),
|
||||
),
|
||||
DivRAligned(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(value),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
Grid(
|
||||
P(description),
|
||||
P(config_type),
|
||||
)
|
||||
if not deprecated
|
||||
else None,
|
||||
Grid(
|
||||
P(deprecated),
|
||||
P("DEPRECATED!"),
|
||||
)
|
||||
if deprecated
|
||||
else None,
|
||||
# Default
|
||||
Grid(
|
||||
DivRAligned(P("default")),
|
||||
P(default),
|
||||
)
|
||||
if read_only == "rw" and not deprecated
|
||||
else None,
|
||||
# Set value
|
||||
Grid(
|
||||
DivRAligned(P("update")),
|
||||
Grid(
|
||||
Form(
|
||||
Input(value=config_name, type="hidden", id="key"),
|
||||
Input(value=update_value, type="text", id="value"),
|
||||
hx_put="/eosdash/configuration",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
),
|
||||
)
|
||||
if read_only == "rw" and not deprecated
|
||||
else None,
|
||||
# Last error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
P(update_error),
|
||||
)
|
||||
if update_error
|
||||
else None,
|
||||
cls="space-y-4 gap-4",
|
||||
open=update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
|
||||
|
||||
def DashboardHeader(title: Optional[str]) -> Div:
|
||||
"""Creates a styled header with a title.
|
||||
|
||||
Args:
|
||||
title (Optional[str]): The title text for the header.
|
||||
|
||||
Returns:
|
||||
Div: A styled `Div` element containing the header.
|
||||
"""
|
||||
if title is None:
|
||||
return Div("", cls="header")
|
||||
return Div(H1(title, cls="text-2xl font-bold mb-4"), cls="header")
|
||||
|
||||
|
||||
def DashboardFooter(*c: Any, path: str) -> Card:
|
||||
"""Creates a styled footer with the provided information.
|
||||
|
||||
The footer content is reloaded every 5 seconds from path.
|
||||
|
||||
Args:
|
||||
path (str): Path to reload footer content from
|
||||
|
||||
Returns:
|
||||
Card: A styled `Card` element containing the footer.
|
||||
"""
|
||||
return Card(
|
||||
Container(*c, id="footer-content"),
|
||||
hx_get=f"{path}",
|
||||
hx_trigger="every 5s",
|
||||
hx_target="#footer-content",
|
||||
hx_swap="innerHTML",
|
||||
)
|
||||
|
||||
|
||||
def DashboardTrigger(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
|
||||
"""Creates a styled button for the dashboard trigger.
|
||||
|
||||
Args:
|
||||
*c: Positional arguments to pass to the button.
|
||||
cls (Optional[str]): Additional CSS classes for styling. Defaults to None.
|
||||
**kwargs: Additional keyword arguments for the button.
|
||||
|
||||
Returns:
|
||||
Button: A styled `Button` component.
|
||||
"""
|
||||
new_cls = f"{ButtonT.primary}"
|
||||
if cls:
|
||||
new_cls += f" {stringify(cls)}"
|
||||
kwargs["cls"] = new_cls
|
||||
return Button(*c, submit=False, **kwargs)
|
||||
|
||||
|
||||
def DashboardTabs(dashboard_items: dict[str, str]) -> Card:
|
||||
"""Creates a dashboard tab with dynamic dashboard items.
|
||||
|
||||
Args:
|
||||
dashboard_items (dict[str, str]): A dictionary of dashboard items where keys are item names
|
||||
and values are paths for navigation.
|
||||
|
||||
Returns:
|
||||
Card: A styled `Card` component containing the dashboard tabs.
|
||||
"""
|
||||
dash_items = [
|
||||
Li(
|
||||
DashboardTrigger(
|
||||
H3(menu),
|
||||
hx_get=f"{path}",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
)
|
||||
for menu, path in dashboard_items.items()
|
||||
]
|
||||
return Card(TabContainer(*dash_items, cls="gap-4"), alt=True)
|
||||
|
||||
|
||||
def DashboardContent(content: Any) -> Card:
|
||||
"""Creates a content section within a styled card.
|
||||
|
||||
Args:
|
||||
content (Any): The content to display.
|
||||
|
||||
Returns:
|
||||
Card: A styled `Card` element containing the content.
|
||||
"""
|
||||
return Card(ScrollArea(Container(content, id="page-content"), cls="h-[75vh] w-full rounded-md"))
|
||||
|
||||
|
||||
def Page(
|
||||
title: Optional[str],
|
||||
dashboard_items: dict[str, str],
|
||||
content: Any,
|
||||
footer_content: Any,
|
||||
footer_path: str,
|
||||
) -> Div:
|
||||
"""Generates a full-page layout with a header, dashboard items, content, and footer.
|
||||
|
||||
Args:
|
||||
title (Optional[str]): The page title.
|
||||
dashboard_items (dict[str, str]): A dictionary of dashboard items.
|
||||
content (Any): The main content for the page.
|
||||
footer_content (Any): Footer content.
|
||||
footer_path (Any): Path to reload footer content from.
|
||||
|
||||
Returns:
|
||||
Div: A `Div` element representing the entire page layout.
|
||||
"""
|
||||
return Container(
|
||||
DashboardHeader(title),
|
||||
DashboardTabs(dashboard_items),
|
||||
DashboardContent(content),
|
||||
DashboardFooter(footer_content, path=footer_path),
|
||||
cls=("bg-background text-foreground w-screen p-4 space-y-4", ContainerT.xl),
|
||||
)
|
@@ -1,549 +0,0 @@
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from monsterui.franken import (
|
||||
H3,
|
||||
H4,
|
||||
Card,
|
||||
Details,
|
||||
Div,
|
||||
DividerLine,
|
||||
DivLAligned,
|
||||
DivRAligned,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
P,
|
||||
Summary,
|
||||
UkIcon,
|
||||
)
|
||||
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
|
||||
from akkudoktoreos.server.dash.components import ConfigCard
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Latest configuration update results
|
||||
# Dictionary of config names and associated dictionary with keys "value", "result", "error", "open".
|
||||
config_update_latest: dict[str, dict[str, Optional[Union[str, bool]]]] = {}
|
||||
|
||||
|
||||
def get_nested_value(
|
||||
dictionary: Union[Dict[str, Any], List[Any]],
|
||||
keys: Sequence[Union[str, int]],
|
||||
default: Optional[T] = None,
|
||||
) -> Union[Any, T]:
|
||||
"""Retrieve a nested value from a dictionary or list using a sequence of keys.
|
||||
|
||||
Args:
|
||||
dictionary (Union[Dict[str, Any], List[Any]]): The nested dictionary or list to search.
|
||||
keys (Sequence[Union[str, int]]): A sequence of keys or indices representing the path to the desired value.
|
||||
default (Optional[T]): A value to return if the path is not found.
|
||||
|
||||
Returns:
|
||||
Union[Any, T]: The value at the specified nested path, or the default value if not found.
|
||||
|
||||
Raises:
|
||||
TypeError: If the input is not a dictionary or list, or if keys are not a sequence.
|
||||
KeyError: If a key is not found in a dictionary.
|
||||
IndexError: If an index is out of range in a list.
|
||||
"""
|
||||
if not isinstance(dictionary, (dict, list)):
|
||||
raise TypeError("The first argument must be a dictionary or list")
|
||||
if not isinstance(keys, Sequence):
|
||||
raise TypeError("Keys must be provided as a sequence (e.g., list, tuple)")
|
||||
|
||||
if not keys:
|
||||
return dictionary
|
||||
|
||||
try:
|
||||
# Traverse the structure
|
||||
current = dictionary
|
||||
for key in keys:
|
||||
if isinstance(current, dict):
|
||||
current = current[str(key)]
|
||||
elif isinstance(current, list):
|
||||
current = current[int(key)]
|
||||
else:
|
||||
raise KeyError(f"Invalid key or index: {key}")
|
||||
return current
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
|
||||
"""Retrieve the default value of a field.
|
||||
|
||||
Args:
|
||||
field_info (Union[FieldInfo, ComputedFieldInfo]): The field metadata from Pydantic.
|
||||
regular_field (bool): Indicates if the field is a regular field.
|
||||
|
||||
Returns:
|
||||
Any: The default value of the field or "N/A" if not a regular field.
|
||||
"""
|
||||
default_value = ""
|
||||
if regular_field:
|
||||
if (val := field_info.default) is not PydanticUndefined:
|
||||
default_value = val
|
||||
else:
|
||||
default_value = "N/A"
|
||||
return default_value
|
||||
|
||||
|
||||
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
|
||||
"""Resolve nested types within a field and return their structure.
|
||||
|
||||
Args:
|
||||
field_type (Any): The type of the field to resolve.
|
||||
parent_types (List[str]): A list of parent type names.
|
||||
|
||||
Returns:
|
||||
List[tuple[Any, List[str]]]: A list of tuples containing resolved types and their parent hierarchy.
|
||||
"""
|
||||
resolved_types: list[tuple[Any, list[str]]] = []
|
||||
|
||||
origin = getattr(field_type, "__origin__", field_type)
|
||||
if origin is Union:
|
||||
for arg in getattr(field_type, "__args__", []):
|
||||
if arg is not type(None):
|
||||
resolved_types.extend(resolve_nested_types(arg, parent_types))
|
||||
else:
|
||||
resolved_types.append((field_type, parent_types))
|
||||
|
||||
return resolved_types
|
||||
|
||||
|
||||
def configuration(
|
||||
model: type[PydanticBaseModel], values: dict, values_prefix: list[str] = []
|
||||
) -> list[dict]:
|
||||
"""Generate configuration details based on provided values and model metadata.
|
||||
|
||||
Args:
|
||||
model (type[PydanticBaseModel]): The Pydantic model to extract configuration from.
|
||||
values (dict): A dictionary containing the current configuration values.
|
||||
values_prefix (list[str]): A list of parent type names that prefixes the model values in the values.
|
||||
|
||||
Returns:
|
||||
list[dict]: A sorted list of configuration details, each represented as a dictionary.
|
||||
"""
|
||||
configs = []
|
||||
inner_types: set[type[PydanticBaseModel]] = set()
|
||||
|
||||
for field_name, field_info in list(model.model_fields.items()) + list(
|
||||
model.model_computed_fields.items()
|
||||
):
|
||||
|
||||
def extract_nested_models(
|
||||
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
|
||||
) -> None:
|
||||
"""Extract nested models from the given subfield information.
|
||||
|
||||
Args:
|
||||
subfield_info (Union[ComputedFieldInfo, FieldInfo]): Field metadata from Pydantic.
|
||||
parent_types (list[str]): A list of parent type names for hierarchical representation.
|
||||
"""
|
||||
nonlocal values, values_prefix
|
||||
regular_field = isinstance(subfield_info, FieldInfo)
|
||||
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
|
||||
|
||||
if subtype in inner_types:
|
||||
return
|
||||
|
||||
nested_types = resolve_nested_types(subtype, [])
|
||||
found_basic = False
|
||||
for nested_type, nested_parent_types in nested_types:
|
||||
if not isinstance(nested_type, type) or not issubclass(
|
||||
nested_type, PydanticBaseModel
|
||||
):
|
||||
if found_basic:
|
||||
continue
|
||||
|
||||
config: dict[str, Optional[Any]] = {}
|
||||
config["name"] = ".".join(values_prefix + parent_types)
|
||||
config["value"] = json.dumps(
|
||||
get_nested_value(values, values_prefix + parent_types, "<unknown>")
|
||||
)
|
||||
config["default"] = json.dumps(get_default_value(subfield_info, regular_field))
|
||||
config["description"] = (
|
||||
subfield_info.description if subfield_info.description else ""
|
||||
)
|
||||
config["deprecated"] = (
|
||||
subfield_info.deprecated if subfield_info.deprecated else None
|
||||
)
|
||||
if isinstance(subfield_info, ComputedFieldInfo):
|
||||
config["read-only"] = "ro"
|
||||
type_description = str(subfield_info.return_type)
|
||||
else:
|
||||
config["read-only"] = "rw"
|
||||
type_description = str(subfield_info.annotation)
|
||||
config["type"] = (
|
||||
type_description.replace("typing.", "")
|
||||
.replace("pathlib.", "")
|
||||
.replace("NoneType", "None")
|
||||
.replace("<class 'float'>", "float")
|
||||
)
|
||||
configs.append(config)
|
||||
found_basic = True
|
||||
else:
|
||||
new_parent_types = parent_types + nested_parent_types
|
||||
inner_types.add(nested_type)
|
||||
for nested_field_name, nested_field_info in list(
|
||||
nested_type.model_fields.items()
|
||||
) + list(nested_type.model_computed_fields.items()):
|
||||
extract_nested_models(
|
||||
nested_field_info,
|
||||
new_parent_types + [nested_field_name],
|
||||
)
|
||||
|
||||
extract_nested_models(field_info, [field_name])
|
||||
return sorted(configs, key=lambda x: x["name"])
|
||||
|
||||
|
||||
def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]:
|
||||
"""Fetch and process configuration data from the specified EOS server.
|
||||
|
||||
Args:
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
|
||||
Returns:
|
||||
List[dict]: A list of processed configuration entries.
|
||||
"""
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
|
||||
# Get current configuration from server
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config", timeout=10)
|
||||
result.raise_for_status()
|
||||
config = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
config = {}
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
|
||||
return configuration(ConfigEOS, config)
|
||||
|
||||
|
||||
def ConfigPlanesCard(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
read_only: str,
|
||||
value: str,
|
||||
default: str,
|
||||
description: str,
|
||||
max_planes: int,
|
||||
update_error: Optional[str],
|
||||
update_value: Optional[str],
|
||||
update_open: Optional[bool],
|
||||
) -> Card:
|
||||
"""Creates a styled configuration card for PV planes.
|
||||
|
||||
This function generates a configuration card that is displayed in the UI with
|
||||
various sections such as configuration name, type, description, default value,
|
||||
current value, and error details. It supports both read-only and editable modes.
|
||||
|
||||
Args:
|
||||
config_name (str): The name of the PV planes configuration.
|
||||
config_type (str): The type of the PV planes configuration.
|
||||
read_only (str): Indicates if the PV planes configuration is read-only ("rw" for read-write,
|
||||
any other value indicates read-only).
|
||||
value (str): The current value of the PV planes configuration.
|
||||
default (str): The default value of the PV planes configuration.
|
||||
description (str): A description of the PV planes configuration.
|
||||
max_planes (int): Maximum number of planes that can be set
|
||||
update_error (Optional[str]): The error message, if any, during the update process.
|
||||
update_value (Optional[str]): The value to be updated, if different from the current value.
|
||||
update_open (Optional[bool]): A flag indicating whether the update section of the card
|
||||
should be initially expanded.
|
||||
|
||||
Returns:
|
||||
Card: A styled Card component containing the PV planes configuration details.
|
||||
"""
|
||||
config_id = config_name.replace(".", "-")
|
||||
# Remember overall planes update status
|
||||
planes_update_error = update_error
|
||||
planes_update_value = update_value
|
||||
if not planes_update_value:
|
||||
planes_update_value = value
|
||||
planes_update_open = update_open
|
||||
if not planes_update_open:
|
||||
planes_update_open = False
|
||||
# Create EOS planes configuration
|
||||
eos_planes = json.loads(value)
|
||||
eos_planes_config = {
|
||||
"pvforecast": {
|
||||
"planes": eos_planes,
|
||||
},
|
||||
}
|
||||
# Create cards for all planes
|
||||
rows = []
|
||||
for i in range(0, max_planes):
|
||||
plane_config = configuration(
|
||||
PVForecastPlaneSetting(),
|
||||
eos_planes_config,
|
||||
values_prefix=["pvforecast", "planes", str(i)],
|
||||
)
|
||||
plane_rows = []
|
||||
plane_update_open = False
|
||||
if eos_planes and len(eos_planes) > i:
|
||||
plane_value = json.dumps(eos_planes[i])
|
||||
else:
|
||||
plane_value = json.dumps(None)
|
||||
for config in plane_config:
|
||||
update_error = config_update_latest.get(config["name"], {}).get("error") # type: ignore
|
||||
update_value = config_update_latest.get(config["name"], {}).get("value") # type: ignore
|
||||
update_open = config_update_latest.get(config["name"], {}).get("open") # type: ignore
|
||||
if update_open:
|
||||
planes_update_open = True
|
||||
plane_update_open = True
|
||||
# Make mypy happy - should never trigger
|
||||
if (
|
||||
not isinstance(update_error, (str, type(None)))
|
||||
or not isinstance(update_value, (str, type(None)))
|
||||
or not isinstance(update_open, (bool, type(None)))
|
||||
):
|
||||
error_msg = "update_error or update_value or update_open of wrong type."
|
||||
logger.error(error_msg)
|
||||
raise TypeError(error_msg)
|
||||
plane_rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
config["deprecated"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
rows.append(
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
H4(f"pvforecast.planes.{i}"),
|
||||
),
|
||||
DivRAligned(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(plane_value),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
*plane_rows,
|
||||
cls="space-y-4 gap-4",
|
||||
open=plane_update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
)
|
||||
|
||||
return Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
P(config_name),
|
||||
),
|
||||
DivRAligned(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(value),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
Grid(
|
||||
P(description),
|
||||
P(config_type),
|
||||
),
|
||||
# Default
|
||||
Grid(
|
||||
DivRAligned(P("default")),
|
||||
P(default),
|
||||
)
|
||||
if read_only == "rw"
|
||||
else None,
|
||||
# Set value
|
||||
Grid(
|
||||
DivRAligned(P("update")),
|
||||
Grid(
|
||||
Form(
|
||||
Input(value=config_name, type="hidden", id="key"),
|
||||
Input(value=planes_update_value, type="text", id="value"),
|
||||
hx_put="/eosdash/configuration",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
),
|
||||
)
|
||||
if read_only == "rw"
|
||||
else None,
|
||||
# Last error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
P(planes_update_error),
|
||||
)
|
||||
if planes_update_error
|
||||
else None,
|
||||
# Now come the single element configs
|
||||
*rows,
|
||||
cls="space-y-4 gap-4",
|
||||
open=planes_update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
|
||||
|
||||
def Configuration(
|
||||
eos_host: str, eos_port: Union[str, int], configuration: Optional[list[dict]] = None
|
||||
) -> Div:
|
||||
"""Create a visual representation of the configuration.
|
||||
|
||||
Args:
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
configuration (Optional[list[dict]]): Optional configuration. If not provided it will be
|
||||
retrievd from EOS.
|
||||
|
||||
Returns:
|
||||
rows: Rows of configuration details.
|
||||
"""
|
||||
if not configuration:
|
||||
configuration = get_configuration(eos_host, eos_port)
|
||||
rows = []
|
||||
last_category = ""
|
||||
# find some special configuration values
|
||||
max_planes = 0
|
||||
for config in configuration:
|
||||
if config["name"] == "pvforecast.max_planes":
|
||||
try:
|
||||
max_planes = int(config["value"])
|
||||
except:
|
||||
max_planes = 0
|
||||
# build visual representation
|
||||
for config in configuration:
|
||||
category = config["name"].split(".")[0]
|
||||
if category != last_category:
|
||||
rows.append(H3(category))
|
||||
rows.append(DividerLine())
|
||||
last_category = category
|
||||
update_error = config_update_latest.get(config["name"], {}).get("error")
|
||||
update_value = config_update_latest.get(config["name"], {}).get("value")
|
||||
update_open = config_update_latest.get(config["name"], {}).get("open")
|
||||
# Make mypy happy - should never trigger
|
||||
if (
|
||||
not isinstance(update_error, (str, type(None)))
|
||||
or not isinstance(update_value, (str, type(None)))
|
||||
or not isinstance(update_open, (bool, type(None)))
|
||||
):
|
||||
error_msg = "update_error or update_value or update_open of wrong type."
|
||||
logger.error(error_msg)
|
||||
raise TypeError(error_msg)
|
||||
if (
|
||||
config["type"]
|
||||
== "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]"
|
||||
and not config["deprecated"]
|
||||
):
|
||||
# Special configuration for PV planes
|
||||
rows.append(
|
||||
ConfigPlanesCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
max_planes,
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
else:
|
||||
rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
config["deprecated"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
return Div(*rows, cls="space-y-4")
|
||||
|
||||
|
||||
def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: str) -> P:
|
||||
"""Update configuration key and create a visual representation of the configuration.
|
||||
|
||||
Args:
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
key (str): configuration key in dot notation
|
||||
value (str): configuration value as json string
|
||||
|
||||
Returns:
|
||||
rows: Rows of configuration details.
|
||||
"""
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
path = key.replace(".", "/")
|
||||
try:
|
||||
data = json.loads(value)
|
||||
except:
|
||||
if value in ("None", "none", "Null", "null"):
|
||||
data = None
|
||||
else:
|
||||
data = value
|
||||
|
||||
error = None
|
||||
config = None
|
||||
try:
|
||||
response = requests.put(f"{server}/v1/config/{path}", json=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
try:
|
||||
# Try to get 'detail' from the JSON response
|
||||
detail = response.json().get(
|
||||
"detail", f"No error details for data '{data}' '{response.text}'"
|
||||
)
|
||||
except ValueError:
|
||||
# Response is not JSON
|
||||
detail = f"No error details for data '{data}' '{response.text}'"
|
||||
error = f"Can not set {key} on {server}: {err}, {detail}"
|
||||
# Mark all updates as closed
|
||||
for k in config_update_latest:
|
||||
config_update_latest[k]["open"] = False
|
||||
# Remember this update as latest one
|
||||
config_update_latest[key] = {
|
||||
"error": error,
|
||||
"result": config,
|
||||
"value": value,
|
||||
"open": True,
|
||||
}
|
||||
if error or config is None:
|
||||
# Reread configuration to be shure we display actual data
|
||||
return Configuration(eos_host, eos_port)
|
||||
# Use configuration already provided
|
||||
return Configuration(eos_host, eos_port, configuration(ConfigEOS, config))
|
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"elecprice": {
|
||||
"charges_kwh": 0.21,
|
||||
"provider": "ElecPriceAkkudoktor"
|
||||
},
|
||||
"general": {
|
||||
"latitude": 52.5,
|
||||
"longitude": 13.4
|
||||
},
|
||||
"prediction": {
|
||||
"historic_hours": 48,
|
||||
"hours": 48
|
||||
},
|
||||
"load": {
|
||||
"provider": "LoadAkkudoktor",
|
||||
"provider_settings": {
|
||||
"loadakkudoktor_year_energy": 20000
|
||||
}
|
||||
},
|
||||
"optimization": {
|
||||
"hours": 48
|
||||
},
|
||||
"pvforecast": {
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": 170,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [
|
||||
20,
|
||||
27,
|
||||
22,
|
||||
20
|
||||
],
|
||||
"inverter_paco": 10000
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": 90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [
|
||||
30,
|
||||
30,
|
||||
30,
|
||||
50
|
||||
],
|
||||
"inverter_paco": 10000
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": 140,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [
|
||||
60,
|
||||
30,
|
||||
0,
|
||||
30
|
||||
],
|
||||
"inverter_paco": 2000
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 185,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [
|
||||
45,
|
||||
25,
|
||||
30,
|
||||
60
|
||||
],
|
||||
"inverter_paco": 1400
|
||||
}
|
||||
],
|
||||
"provider": "PVForecastAkkudoktor"
|
||||
},
|
||||
"server": {
|
||||
"startup_eosdash": true,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8503,
|
||||
"eosdash_host": "127.0.0.1",
|
||||
"eosdash_port": 8504
|
||||
},
|
||||
"weather": {
|
||||
"provider": "BrightSky"
|
||||
}
|
||||
}
|
@@ -1,280 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
|
||||
from bokeh.plotting import figure
|
||||
from monsterui.franken import FT, Grid, P
|
||||
|
||||
from akkudoktoreos.core.pydantic import PydanticDateTimeDataFrame
|
||||
from akkudoktoreos.server.dash.bokeh import Bokeh
|
||||
|
||||
DIR_DEMODATA = Path(__file__).absolute().parent.joinpath("data")
|
||||
FILE_DEMOCONFIG = DIR_DEMODATA.joinpath("democonfig.json")
|
||||
if not FILE_DEMOCONFIG.exists():
|
||||
raise ValueError(f"File does not exist: {FILE_DEMOCONFIG}")
|
||||
|
||||
# bar width for 1 hour bars (time given in millseconds)
|
||||
BAR_WIDTH_1HOUR = 1000 * 60 * 60
|
||||
|
||||
|
||||
def DemoPVForecast(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["pvforecast"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
x_axis_type="datetime",
|
||||
title=f"PV Power Prediction ({provider})",
|
||||
x_axis_label="Datetime",
|
||||
y_axis_label="Power [W]",
|
||||
sizing_mode="stretch_width",
|
||||
height=400,
|
||||
)
|
||||
|
||||
plot.vbar(
|
||||
x="date_time",
|
||||
top="pvforecast_ac_power",
|
||||
source=source,
|
||||
width=BAR_WIDTH_1HOUR * 0.8,
|
||||
legend_label="AC Power",
|
||||
color="lightblue",
|
||||
)
|
||||
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
def DemoElectricityPriceForecast(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["elecprice"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
x_axis_type="datetime",
|
||||
y_range=Range1d(
|
||||
predictions["elecprice_marketprice_kwh"].min() - 0.1,
|
||||
predictions["elecprice_marketprice_kwh"].max() + 0.1,
|
||||
),
|
||||
title=f"Electricity Price Prediction ({provider})",
|
||||
x_axis_label="Datetime",
|
||||
y_axis_label="Price [€/kWh]",
|
||||
sizing_mode="stretch_width",
|
||||
height=400,
|
||||
)
|
||||
plot.vbar(
|
||||
x="date_time",
|
||||
top="elecprice_marketprice_kwh",
|
||||
source=source,
|
||||
width=BAR_WIDTH_1HOUR * 0.8,
|
||||
legend_label="Market Price",
|
||||
color="lightblue",
|
||||
)
|
||||
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
def DemoWeatherTempAirHumidity(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["weather"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
x_axis_type="datetime",
|
||||
title=f"Air Temperature and Humidity Prediction ({provider})",
|
||||
x_axis_label="Datetime",
|
||||
y_axis_label="Temperature [°C]",
|
||||
sizing_mode="stretch_width",
|
||||
height=400,
|
||||
)
|
||||
# Add secondary y-axis for humidity
|
||||
plot.extra_y_ranges["humidity"] = Range1d(start=-5, end=105)
|
||||
y2_axis = LinearAxis(y_range_name="humidity", axis_label="Relative Humidity [%]")
|
||||
y2_axis.axis_label_text_color = "green"
|
||||
plot.add_layout(y2_axis, "left")
|
||||
|
||||
plot.line(
|
||||
"date_time", "weather_temp_air", source=source, legend_label="Air Temperature", color="blue"
|
||||
)
|
||||
|
||||
plot.line(
|
||||
"date_time",
|
||||
"weather_relative_humidity",
|
||||
source=source,
|
||||
legend_label="Relative Humidity [%]",
|
||||
color="green",
|
||||
y_range_name="humidity",
|
||||
)
|
||||
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
def DemoWeatherIrradiance(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["weather"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
x_axis_type="datetime",
|
||||
title=f"Irradiance Prediction ({provider})",
|
||||
x_axis_label="Datetime",
|
||||
y_axis_label="Irradiance [W/m2]",
|
||||
sizing_mode="stretch_width",
|
||||
height=400,
|
||||
)
|
||||
plot.line(
|
||||
"date_time",
|
||||
"weather_ghi",
|
||||
source=source,
|
||||
legend_label="Global Horizontal Irradiance",
|
||||
color="red",
|
||||
)
|
||||
plot.line(
|
||||
"date_time",
|
||||
"weather_dni",
|
||||
source=source,
|
||||
legend_label="Direct Normal Irradiance",
|
||||
color="green",
|
||||
)
|
||||
plot.line(
|
||||
"date_time",
|
||||
"weather_dhi",
|
||||
source=source,
|
||||
legend_label="Diffuse Horizontal Irradiance",
|
||||
color="blue",
|
||||
)
|
||||
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
def DemoLoad(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["load"]["provider"]
|
||||
if provider == "LoadAkkudoktor":
|
||||
year_energy = config["load"]["provider_settings"]["loadakkudoktor_year_energy"]
|
||||
provider = f"{provider}, {year_energy} kWh"
|
||||
|
||||
plot = figure(
|
||||
x_axis_type="datetime",
|
||||
title=f"Load Prediction ({provider})",
|
||||
x_axis_label="Datetime",
|
||||
y_axis_label="Load [W]",
|
||||
sizing_mode="stretch_width",
|
||||
height=400,
|
||||
)
|
||||
# Add secondary y-axis for stddev
|
||||
stddev_min = predictions["load_std"].min()
|
||||
stddev_max = predictions["load_std"].max()
|
||||
plot.extra_y_ranges["stddev"] = Range1d(start=stddev_min - 5, end=stddev_max + 5)
|
||||
y2_axis = LinearAxis(y_range_name="stddev", axis_label="Load Standard Deviation [W]")
|
||||
y2_axis.axis_label_text_color = "green"
|
||||
plot.add_layout(y2_axis, "left")
|
||||
|
||||
plot.line(
|
||||
"date_time",
|
||||
"load_mean",
|
||||
source=source,
|
||||
legend_label="Load mean value",
|
||||
color="red",
|
||||
)
|
||||
plot.line(
|
||||
"date_time",
|
||||
"load_mean_adjusted",
|
||||
source=source,
|
||||
legend_label="Load adjusted by measurement",
|
||||
color="blue",
|
||||
)
|
||||
plot.line(
|
||||
"date_time",
|
||||
"load_std",
|
||||
source=source,
|
||||
legend_label="Load standard deviation",
|
||||
color="green",
|
||||
y_range_name="stddev",
|
||||
)
|
||||
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
|
||||
# Get current configuration from server
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config", timeout=10)
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
detail = result.json()["detail"]
|
||||
return P(
|
||||
f"Can not retrieve configuration from {server}: {err}, {detail}",
|
||||
cls="text-center",
|
||||
)
|
||||
config = result.json()
|
||||
|
||||
# Set demo configuration
|
||||
with FILE_DEMOCONFIG.open("r", encoding="utf-8") as fd:
|
||||
democonfig = json.load(fd)
|
||||
try:
|
||||
result = requests.put(f"{server}/v1/config", json=democonfig, timeout=10)
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
detail = result.json()["detail"]
|
||||
# Try to reset to original config
|
||||
requests.put(f"{server}/v1/config", json=config, timeout=10)
|
||||
return P(
|
||||
f"Can not set demo configuration on {server}: {err}, {detail}",
|
||||
cls="text-center",
|
||||
)
|
||||
|
||||
# Update all predictions
|
||||
try:
|
||||
result = requests.post(f"{server}/v1/prediction/update", timeout=10)
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
detail = result.json()["detail"]
|
||||
# Try to reset to original config
|
||||
requests.put(f"{server}/v1/config", json=config, timeout=10)
|
||||
return P(
|
||||
f"Can not update predictions on {server}: {err}, {detail}",
|
||||
cls="text-center",
|
||||
)
|
||||
|
||||
# Get Forecasts
|
||||
try:
|
||||
params = {
|
||||
"keys": [
|
||||
"pvforecast_ac_power",
|
||||
"elecprice_marketprice_kwh",
|
||||
"weather_relative_humidity",
|
||||
"weather_temp_air",
|
||||
"weather_ghi",
|
||||
"weather_dni",
|
||||
"weather_dhi",
|
||||
"load_mean",
|
||||
"load_std",
|
||||
"load_mean_adjusted",
|
||||
],
|
||||
}
|
||||
result = requests.get(f"{server}/v1/prediction/dataframe", params=params, timeout=10)
|
||||
result.raise_for_status()
|
||||
predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
detail = result.json()["detail"]
|
||||
return P(
|
||||
f"Can not retrieve predictions from {server}: {err}, {detail}",
|
||||
cls="text-center",
|
||||
)
|
||||
except Exception as err:
|
||||
return P(
|
||||
f"Can not retrieve predictions from {server}: {err}",
|
||||
cls="text-center",
|
||||
)
|
||||
|
||||
# Reset to original config
|
||||
requests.put(f"{server}/v1/config", json=config, timeout=10)
|
||||
|
||||
return Grid(
|
||||
DemoPVForecast(predictions, democonfig),
|
||||
DemoElectricityPriceForecast(predictions, democonfig),
|
||||
DemoWeatherTempAirHumidity(predictions, democonfig),
|
||||
DemoWeatherIrradiance(predictions, democonfig),
|
||||
DemoLoad(predictions, democonfig),
|
||||
cols_max=2,
|
||||
)
|
@@ -1,91 +0,0 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from monsterui.daisy import Loading, LoadingT
|
||||
from monsterui.franken import A, ButtonT, DivFullySpaced, P
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
|
||||
config_eos = get_config()
|
||||
|
||||
|
||||
def get_alive(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
"""Fetch alive information from the specified EOS server.
|
||||
|
||||
Args:
|
||||
eos_host (str): The hostname of the server.
|
||||
eos_port (Union[str, int]): The port of the server.
|
||||
|
||||
Returns:
|
||||
str: Alive data.
|
||||
"""
|
||||
result = requests.Response()
|
||||
try:
|
||||
result = requests.get(f"http://{eos_host}:{eos_port}/v1/health", timeout=10)
|
||||
if result.status_code == 200:
|
||||
alive = result.json()["status"]
|
||||
else:
|
||||
alive = f"Server responded with status code: {result.status_code}"
|
||||
except RequestException as e:
|
||||
warning_msg = f"{e}"
|
||||
logger.warning(warning_msg)
|
||||
alive = warning_msg
|
||||
|
||||
return alive
|
||||
|
||||
|
||||
def Footer(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> str:
|
||||
if eos_host is None:
|
||||
eos_host = config_eos.server.host
|
||||
if eos_port is None:
|
||||
eos_port = config_eos.server.port
|
||||
alive_icon = None
|
||||
if eos_host is None or eos_port is None:
|
||||
alive = "EOS server not given: {eos_host}:{eos_port}"
|
||||
else:
|
||||
alive = get_alive(eos_host, eos_port)
|
||||
if alive == "alive":
|
||||
alive_icon = Loading(
|
||||
cls=(
|
||||
LoadingT.ring,
|
||||
LoadingT.sm,
|
||||
),
|
||||
)
|
||||
alive = f"EOS {eos_host}:{eos_port}"
|
||||
if alive_icon:
|
||||
alive_cls = f"{ButtonT.primary} uk-link rounded-md"
|
||||
else:
|
||||
alive_cls = f"{ButtonT.secondary} uk-link rounded-md"
|
||||
return DivFullySpaced(
|
||||
P(
|
||||
alive_icon,
|
||||
A(alive, href=f"http://{eos_host}:{eos_port}/docs", target="_blank", cls=alive_cls),
|
||||
),
|
||||
P(
|
||||
A(
|
||||
"Documentation",
|
||||
href="https://akkudoktor-eos.readthedocs.io/en/latest/",
|
||||
target="_blank",
|
||||
cls="uk-link",
|
||||
),
|
||||
),
|
||||
P(
|
||||
A(
|
||||
"Issues",
|
||||
href="https://github.com/Akkudoktor-EOS/EOS/issues",
|
||||
target="_blank",
|
||||
cls="uk-link",
|
||||
),
|
||||
),
|
||||
P(
|
||||
A(
|
||||
"GitHub",
|
||||
href="https://github.com/Akkudoktor-EOS/EOS/",
|
||||
target="_blank",
|
||||
cls="uk-link",
|
||||
),
|
||||
),
|
||||
cls="uk-padding-remove-top uk-padding-remove-botton",
|
||||
)
|