18 Commits

Author SHA1 Message Date
Dominique Lasserre
87ac127817 Workflow: Docker: Add more archs: armv6/v7, i386
* Build amd64 on any PR.
2025-02-10 23:37:48 +01:00
Normann
480adf8100 Data prefetch ems for feature (#420)
* Pre-fetch data

* maintanance and extend tests

* comment clean up

* nansum usage (to be save)

* Feature/config nested (#421)

* Nested config, devices registry

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

 * Fix multi-initialization of derived SingletonMixin classes.

* Documentation: Support nested config

 * Add examples to pydantic models.

* EOSdash: Support nested types

* Rename settings variables (remove prefixes)

* Fix API endpoint

* Fix EOSdash startup (docker)

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

* PR review

* PVForecast: planes as nested config (list)

* Update manual documentation for nested config.

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

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

* Docs: Add global example documentation.

 * merge_models: Use deecopy to not change input data.

* EOSdash: Sort config by name

* Review comments

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

* Bump numpydantic from 1.6.4 to 1.6.7 (#413)

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

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

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

* Bump timezonefinder from 6.5.7 to 6.5.8 (#414)

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

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

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

* Bump pydantic from 2.10.5 to 2.10.6 (#412)

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

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

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

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

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

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

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

---------

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

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

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

* inverter added

* png creation

* save svg into cache folder

* mypy

* comment

---------

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

* inverter, prediction.hours

* self.config.general.data_cache_path

---------

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

* png creation

* save svg into cache folder

* mypy

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

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

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

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

* Bump timezonefinder from 6.5.7 to 6.5.8 (#414)

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

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

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

* Bump pydantic from 2.10.5 to 2.10.6 (#412)

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

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

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

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

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

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

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

---------

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

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

View File

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

5
.env
View File

@@ -1,4 +1,7 @@
EOS_VERSION=main EOS_VERSION=main
EOS_PORT=8503 EOS_SERVER__PORT=8503
EOS_SERVER__EOSDASH_PORT=8504
PYTHON_VERSION=3.12.6 PYTHON_VERSION=3.12.6
BASE_IMAGE=python
IMAGE_SUFFIX=-slim

View File

@@ -7,13 +7,11 @@ on:
push: push:
branches: branches:
- 'main' - 'main'
- 'feature/config-overhaul'
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
branches: branches:
- 'main' - '**'
- 'feature/config-overhaul'
env: env:
DOCKERHUB_REPO: akkudoktor/eos DOCKERHUB_REPO: akkudoktor/eos
@@ -40,7 +38,9 @@ jobs:
run: | run: |
if ${{ github.event_name == 'pull_request' }}; then if ${{ github.event_name == 'pull_request' }}; then
echo 'matrix=[ echo 'matrix=[
{"platform": "linux/arm64"} {"platform": {"name": "linux/amd64"}},
{"platform": {"name": "linux/arm64"}},
{"platform": {"name": "linux/386"}},
]' | tr -d '[:space:]' >> $GITHUB_OUTPUT ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT
else else
echo 'matrix=[]' >> $GITHUB_OUTPUT echo 'matrix=[]' >> $GITHUB_OUTPUT
@@ -58,13 +58,69 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
platform: platform:
- linux/amd64 - name: linux/amd64
- linux/arm64 base: python
python: 3.12 # pendulum not yet on pypi for 3.13
rustup_install: ""
apt_packages: ""
apt_build_packages: ""
pip_extra_url: ""
- name: linux/arm64
base: python
python: 3.12 # pendulum not yet on pypi for 3.13
rustup_install: ""
apt_packages: ""
apt_build_packages: ""
pip_extra_url: ""
- name: linux/arm/v6
base: python
python: 3.11 # highest version on piwheels
rustup_install: true
# numpy: libopenblas0
# h5py: libhdf5-hl-310
#apt_packages: "libopenblas0 libhdf5-hl-310"
apt_packages: "" #TODO verify
# pendulum: git (apply patch)
# matplotlib (countourpy): g++
# fastapi (MarkupSafe): gcc
# rustup installer: curl
apt_build_packages: "curl git g++"
pip_extra_url: "https://www.piwheels.org/simple" # armv6/v7 packages
- name: linux/arm/v7
base: python
python: 3.11 # highest version on piwheels
rustup_install: true
# numpy: libopenblas0
# h5py: libhdf5-hl-310
#apt_packages: "libopenblas0 libhdf5-hl-310"
apt_packages: "" #TODO verify
# pendulum: git (apply patch)
# matplotlib (countourpy): g++
# fastapi (MarkupSafe): gcc
# rustup installer: curl
apt_build_packages: "curl git g++"
pip_extra_url: "https://www.piwheels.org/simple" # armv6/v7 packages
- name: linux/386
# Get 32bit distributor fix for pendulum, not yet officially released.
# Needs Debian testing instead of python:xyz which is based on Debian stable.
base: debian
python: trixie
rustup_install: ""
# numpy: libopenblas0
# h5py: libhdf5-hl-310
apt_packages: "python3-pendulum python3-pip libopenblas0 libhdf5-hl-310"
# numpy: g++, libc-dev
# skikit: pkgconf python3-dev, libopenblas-dev
# uvloop: make
# h5py: libhdf5-dev
# many others g++/gcc
apt_build_packages: "g++ pkgconf libc-dev python3-dev make libopenblas-dev libhdf5-dev"
pip_extra_url: ""
exclude: ${{ fromJSON(needs.platform-excludes.outputs.excludes) }} exclude: ${{ fromJSON(needs.platform-excludes.outputs.excludes) }}
steps: steps:
- name: Prepare - name: Prepare
run: | run: |
platform=${{ matrix.platform }} platform=${{ matrix.platform.name }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Docker meta - name: Docker meta
@@ -98,7 +154,8 @@ jobs:
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v3
# skip for pull requests # skip for pull requests
if: ${{ github.event_name != 'pull_request' }} #TODO: uncomment again
#if: ${{ github.event_name != 'pull_request' }}
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -106,8 +163,7 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
# skip for pull requests #if: ${{ github.event_name != 'pull_request' }}
if: ${{ github.event_name != 'pull_request' }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -116,10 +172,19 @@ jobs:
id: build id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform.name }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }} annotations: ${{ steps.meta.outputs.annotations }}
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,"push=${{ github.event_name != 'pull_request' }}","annotation-index.org.opencontainers.image.description=${{ env.EOS_REPO_DESCRIPTION }}" #TODO: uncomment again
#outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,"push=${{ github.event_name != 'pull_request' }}","annotation-index.org.opencontainers.image.description=${{ env.EOS_REPO_DESCRIPTION }}"
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true,"annotation-index.org.opencontainers.image.description=${{ env.EOS_REPO_DESCRIPTION }}"
build-args: |
BASE_IMAGE=${{ matrix.platform.base }}
PYTHON_VERSION=${{ matrix.platform.python }}
PIP_EXTRA_INDEX_URL=${{ matrix.platform.pip_extra_url }}
APT_PACKAGES=${{ matrix.platform.apt_packages }}
APT_BUILD_PACKAGES=${{ matrix.platform.apt_build_packages }}
RUSTUP_INSTALL=${{ matrix.platform.rustup_install }}
- name: Generate artifact attestation DockerHub - name: Generate artifact attestation DockerHub
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2

3
.gitignore vendored
View File

@@ -260,3 +260,6 @@ tests/testdata/new_optimize_result*
tests/testdata/openapi-new.json tests/testdata/openapi-new.json
tests/testdata/openapi-new.md tests/testdata/openapi-new.md
tests/testdata/config-new.md tests/testdata/config-new.md
# FastHTML session key
.sesskey

View File

@@ -1,10 +1,10 @@
ARG PYTHON_VERSION=3.12.7 ARG PYTHON_VERSION=3.12.8
FROM python:${PYTHON_VERSION}-slim ARG BASE_IMAGE=python
ARG IMAGE_SUFFIX=-slim
FROM ${BASE_IMAGE}:${PYTHON_VERSION}${IMAGE_SUFFIX} AS base
LABEL source="https://github.com/Akkudoktor-EOS/EOS" LABEL source="https://github.com/Akkudoktor-EOS/EOS"
ENV VIRTUAL_ENV="/opt/venv"
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
ENV MPLCONFIGDIR="/tmp/mplconfigdir" ENV MPLCONFIGDIR="/tmp/mplconfigdir"
ENV EOS_DIR="/opt/eos" ENV EOS_DIR="/opt/eos"
ENV EOS_CACHE_DIR="${EOS_DIR}/cache" ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
@@ -13,7 +13,8 @@ ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
WORKDIR ${EOS_DIR} WORKDIR ${EOS_DIR}
RUN adduser --system --group --no-create-home eos \ # Use useradd over adduser to support both debian:x-slim and python:x-slim base images
RUN useradd --system --no-create-home --shell /usr/sbin/nologin eos \
&& mkdir -p "${MPLCONFIGDIR}" \ && mkdir -p "${MPLCONFIGDIR}" \
&& chown eos "${MPLCONFIGDIR}" \ && chown eos "${MPLCONFIGDIR}" \
&& mkdir -p "${EOS_CACHE_DIR}" \ && mkdir -p "${EOS_CACHE_DIR}" \
@@ -23,13 +24,85 @@ RUN adduser --system --group --no-create-home eos \
&& mkdir -p "${EOS_CONFIG_DIR}" \ && mkdir -p "${EOS_CONFIG_DIR}" \
&& chown eos "${EOS_CONFIG_DIR}" && chown eos "${EOS_CONFIG_DIR}"
ARG APT_PACKAGES
ENV APT_PACKAGES="${APT_PACKAGES}"
RUN --mount=type=cache,sharing=locked,target=/var/lib/apt/lists \
--mount=type=cache,sharing=locked,target=/var/cache/apt \
rm /etc/apt/apt.conf.d/docker-clean; \
if [ -n "${APT_PACKAGES}" ]; then \
apt-get update \
&& apt-get install -y --no-install-recommends ${APT_PACKAGES}; \
fi
FROM base AS build
ARG APT_BUILD_PACKAGES
ENV APT_BUILD_PACKAGES="${APT_BUILD_PACKAGES}"
RUN --mount=type=cache,sharing=locked,target=/var/lib/apt/lists \
--mount=type=cache,sharing=locked,target=/var/cache/apt \
rm /etc/apt/apt.conf.d/docker-clean; \
if [ -n "${APT_BUILD_PACKAGES}" ]; then \
apt-get update \
&& apt-get install -y --no-install-recommends ${APT_BUILD_PACKAGES}; \
fi
ARG RUSTUP_INSTALL
ENV RUSTUP_INSTALL="${RUSTUP_INSTALL}"
ENV RUSTUP_HOME=/opt/rust
ENV CARGO_HOME=/opt/rust
ENV PATH="$RUSTUP_HOME/bin:$PATH"
ARG PIP_EXTRA_INDEX_URL
ENV PIP_EXTRA_INDEX_URL="${PIP_EXTRA_INDEX_URL}"
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=tmpfs,target=/root/.cargo \
dpkgArch=$(dpkg --print-architecture) \
&& if [ -n "${RUSTUP_INSTALL}" ]; then \
case "$dpkgArch" in \
# armv6
armel) \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --target arm-unknown-linux-gnueabi --no-modify-path \
;; \
*) \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path \
;; \
esac \
&& rustc --version \
&& cargo --version; \
fi \
# Install 32bit fix for pendulum, can be removed after next pendulum release (> 3.0.0)
&& case "$dpkgArch" in \
# armv7/armv6
armhf|armel) \
git clone https://github.com/python-pendulum/pendulum.git \
&& git -C pendulum checkout -b 3.0.0 3.0.0 \
# Apply 32bit patch
&& git -C pendulum -c user.name=ci -c user.email=ci@github.com cherry-pick b84b97625cdea00f8ab150b8b35aa5ccaaf36948 \
&& cd pendulum \
# Use pip3 over pip to support both debian:x and python:x base images
&& pip3 install maturin \
&& maturin build --release --out dist \
&& pip3 install dist/*.whl --break-system-packages \
&& cd - \
;; \
esac
COPY requirements.txt . COPY requirements.txt .
# Use tmpfs for cargo due to qemu (multiarch) limitations
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt --mount=type=tmpfs,target=/root/.cargo \
# Use pip3 over pip to support both debian:x and python:x base images
pip3 install -r requirements.txt --break-system-packages
FROM base AS final
# Copy all python dependencies previously installed or built to the final stage.
COPY --from=build /usr/local/ /usr/local/
COPY --from=build /opt/eos/requirements.txt .
COPY pyproject.toml . COPY pyproject.toml .
RUN mkdir -p src && pip install -e . RUN --mount=type=cache,target=/root/.cache/pip \
# Use pip3 over pip to support both debian:x and python:x base images
mkdir -p src && pip3 install -e . --break-system-packages
COPY src src COPY src src
@@ -39,6 +112,7 @@ ENTRYPOINT []
EXPOSE 8503 EXPOSE 8503
EXPOSE 8504 EXPOSE 8504
CMD ["python", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"] # Use python3 over python to support both debian:x and python:x base images
CMD ["python3", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"]
VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"] VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"]

View File

@@ -52,6 +52,8 @@ Windows:
docker compose up docker compose up
``` ```
If you are running the EOS container on a system hosting multiple services, such as a Synology NAS, and want to allow external network access to EOS, please ensure that the default exported ports (8503, 8504) are available on the host. On Synology systems, these ports might already be in use (refer to [this guide](https://kb.synology.com/en-me/DSM/tutorial/What_network_ports_are_used_by_Synology_services)). If the ports are occupied, you will need to reconfigure the exported ports accordingly.
## Configuration ## Configuration
This project uses the `EOS.config.json` file to manage configuration settings. This project uses the `EOS.config.json` file to manage configuration settings.

View File

@@ -11,12 +11,21 @@ services:
dockerfile: "Dockerfile" dockerfile: "Dockerfile"
args: args:
PYTHON_VERSION: "${PYTHON_VERSION}" PYTHON_VERSION: "${PYTHON_VERSION}"
BASE_IMAGE: "${BASE_IMAGE}"
IMAGE_SUFFIX: "${IMAGE_SUFFIX}"
APT_PACKAGES: "${APT_PACKAGES:-}"
APT_BUILD_PACKAGES: "${APT_BUILD_PACKAGES:-}"
PIP_EXTRA_INDEX_URL: "${PIP_EXTRA_INDEX_URL:-}"
RUSTUP_INSTALL: "${RUSTUP_INSTALL:-}"
env_file:
- .env
environment: environment:
- EOS_CONFIG_DIR=config - EOS_CONFIG_DIR=config
- latitude=52.2 - EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
- longitude=13.4 - EOS_PREDICTION__LATITUDE=52.2
- elecprice_provider=ElecPriceAkkudoktor - EOS_PREDICTION__LONGITUDE=13.4
- elecprice_charges_kwh=0.21 - EOS_ELECPRICE__PROVIDER=ElecPriceAkkudoktor
- server_fasthtml_host=none - EOS_ELECPRICE__CHARGES_KWH=0.21
ports: ports:
- "${EOS_PORT}:${EOS_PORT}" - "${EOS_SERVER__PORT}:${EOS_SERVER__PORT}"
- "${EOS_SERVER__EOSDASH_PORT}:${EOS_SERVER__EOSDASH_PORT}"

File diff suppressed because it is too large Load Diff

View File

@@ -63,7 +63,7 @@ Args:
year_energy (float): Yearly energy consumption in Wh. year_energy (float): Yearly energy consumption in Wh.
Note: Note:
Set LoadAkkudoktor as load_provider, then update data with Set LoadAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=load_mean' instead. '/v1/prediction/list?key=load_mean' instead.
@@ -91,6 +91,8 @@ Fastapi Optimize
- `start_hour` (query, optional): Defaults to current hour of the day. - `start_hour` (query, optional): Defaults to current hour of the day.
- `ngen` (query, optional): No description provided.
**Request Body**: **Request Body**:
- `application/json`: { - `application/json`: {
@@ -121,7 +123,7 @@ If no forecast values are available the missing ones at the start of the series
filled with the first available forecast value. filled with the first available forecast value.
Note: Note:
Set PVForecastAkkudoktor as pvforecast_provider, then update data with Set PVForecastAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=pvforecast_ac_power' and '/v1/prediction/list?key=pvforecast_ac_power' and
@@ -151,7 +153,7 @@ Note:
Electricity price charges are added. Electricity price charges are added.
Note: Note:
Set ElecPriceAkkudoktor as elecprice_provider, then update data with Set ElecPriceAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=elecprice_marketprice_wh' or '/v1/prediction/list?key=elecprice_marketprice_wh' or
@@ -190,11 +192,11 @@ Returns:
Fastapi Config Put Fastapi Config Put
``` ```
Write the provided settings into the current settings. Update the current config with the provided settings.
The existing settings are completely overwritten. Note that for any setting Note that for any setting value that is None or unset, the configuration will fall back to
value that is None, the configuration will fall back to values from other sources such as values from other sources such as environment variables, the EOS configuration file, or default
environment variables, the EOS configuration file, or default values. values.
Args: Args:
settings (SettingsEOS): The settings to write into the current settings. settings (SettingsEOS): The settings to write into the current settings.
@@ -203,311 +205,11 @@ Returns:
configuration (ConfigEOS): The current configuration after the write. configuration (ConfigEOS): The current configuration after the write.
``` ```
**Parameters**: **Request Body**:
- `server_eos_host` (query, optional): EOS server IP address. - `application/json`: {
"$ref": "#/components/schemas/SettingsEOS"
- `server_eos_port` (query, optional): EOS server IP port number. }
- `server_eos_verbose` (query, optional): Enable debug output
- `server_eos_startup_eosdash` (query, optional): EOS server to start EOSdash server.
- `server_eosdash_host` (query, optional): EOSdash server IP address.
- `server_eosdash_port` (query, optional): EOSdash server IP port number.
- `weatherimport_file_path` (query, optional): Path to the file to import weather data from.
- `weatherimport_json` (query, optional): JSON string, dictionary of weather forecast value lists.
- `weather_provider` (query, optional): Weather provider id of provider to be used.
- `pvforecastimport_file_path` (query, optional): Path to the file to import PV forecast data from.
- `pvforecastimport_json` (query, optional): JSON string, dictionary of PV forecast value lists.
- `pvforecast_provider` (query, optional): PVForecast provider id of provider to be used.
- `pvforecast0_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast0_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast0_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast0_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast0_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast0_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast0_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast0_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast0_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast0_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast0_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast0_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast0_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast0_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast0_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast0_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast1_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast1_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast1_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast1_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast1_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast1_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast1_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast1_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast1_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast1_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast1_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast1_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast1_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast1_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast1_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast1_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast2_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast2_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast2_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast2_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast2_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast2_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast2_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast2_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast2_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast2_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast2_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast2_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast2_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast2_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast2_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast2_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast3_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast3_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast3_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast3_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast3_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast3_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast3_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast3_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast3_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast3_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast3_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast3_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast3_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast3_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast3_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast3_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast4_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast4_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast4_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast4_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast4_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast4_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast4_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast4_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast4_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast4_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast4_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast4_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast4_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast4_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast4_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast4_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `pvforecast5_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast5_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast5_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast5_peakpower` (query, optional): Nominal power of PV system in kW.
- `pvforecast5_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast5_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast5_loss` (query, optional): Sum of PV system losses in percent
- `pvforecast5_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast5_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `pvforecast5_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast5_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
- `pvforecast5_module_model` (query, optional): Model of the PV modules of this plane.
- `pvforecast5_inverter_model` (query, optional): Model of the inverter of this plane.
- `pvforecast5_inverter_paco` (query, optional): AC power rating of the inverter. [W]
- `pvforecast5_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
- `pvforecast5_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
- `load_import_file_path` (query, optional): Path to the file to import load data from.
- `load_import_json` (query, optional): JSON string, dictionary of load forecast value lists.
- `loadakkudoktor_year_energy` (query, optional): Yearly energy consumption (kWh).
- `load_provider` (query, optional): Load provider id of provider to be used.
- `elecpriceimport_file_path` (query, optional): Path to the file to import elecprice data from.
- `elecpriceimport_json` (query, optional): JSON string, dictionary of electricity price forecast value lists.
- `elecprice_provider` (query, optional): Electricity price provider id of provider to be used.
- `elecprice_charges_kwh` (query, optional): Electricity price charges (€/kWh).
- `prediction_hours` (query, optional): Number of hours into the future for predictions
- `prediction_historic_hours` (query, optional): Number of hours into the past for historical predictions data
- `latitude` (query, optional): Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)
- `longitude` (query, optional): Longitude in decimal degrees, within -180 to 180 (°)
- `optimization_hours` (query, optional): Number of hours into the future for optimizations.
- `optimization_penalty` (query, optional): Penalty factor used in optimization.
- `optimization_ev_available_charge_rates_percent` (query, optional): Charge rates available for the EV in percent of maximum charge.
- `measurement_load0_name` (query, optional): Name of the load0 source (e.g. 'Household', 'Heat Pump')
- `measurement_load1_name` (query, optional): Name of the load1 source (e.g. 'Household', 'Heat Pump')
- `measurement_load2_name` (query, optional): Name of the load2 source (e.g. 'Household', 'Heat Pump')
- `measurement_load3_name` (query, optional): Name of the load3 source (e.g. 'Household', 'Heat Pump')
- `measurement_load4_name` (query, optional): Name of the load4 source (e.g. 'Household', 'Heat Pump')
- `battery_provider` (query, optional): Id of Battery simulation provider.
- `battery_capacity` (query, optional): Battery capacity [Wh].
- `battery_initial_soc` (query, optional): Battery initial state of charge [%].
- `battery_soc_min` (query, optional): Battery minimum state of charge [%].
- `battery_soc_max` (query, optional): Battery maximum state of charge [%].
- `battery_charging_efficiency` (query, optional): Battery charging efficiency [%].
- `battery_discharging_efficiency` (query, optional): Battery discharging efficiency [%].
- `battery_max_charging_power` (query, optional): Battery maximum charge power [W].
- `bev_provider` (query, optional): Id of Battery Electric Vehicle simulation provider.
- `bev_capacity` (query, optional): Battery Electric Vehicle capacity [Wh].
- `bev_initial_soc` (query, optional): Battery Electric Vehicle initial state of charge [%].
- `bev_soc_max` (query, optional): Battery Electric Vehicle maximum state of charge [%].
- `bev_charging_efficiency` (query, optional): Battery Electric Vehicle charging efficiency [%].
- `bev_discharging_efficiency` (query, optional): Battery Electric Vehicle discharging efficiency [%].
- `bev_max_charging_power` (query, optional): Battery Electric Vehicle maximum charge power [W].
- `dishwasher_provider` (query, optional): Id of Dish Washer simulation provider.
- `dishwasher_consumption` (query, optional): Dish Washer energy consumption [Wh].
- `dishwasher_duration` (query, optional): Dish Washer usage duration [h].
- `inverter_provider` (query, optional): Id of PV Inverter simulation provider.
- `inverter_power_max` (query, optional): Inverter maximum power [W].
- `logging_level_default` (query, optional): EOS default logging level.
- `data_folder_path` (query, optional): Path to EOS data directory.
- `data_output_subpath` (query, optional): Sub-path for the EOS output data directory.
- `data_cache_subpath` (query, optional): Sub-path for the EOS cache data directory.
**Responses**: **Responses**:
@@ -517,25 +219,6 @@ Returns:
--- ---
## GET /v1/config/file
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_file_get_v1_config_file_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_file_get_v1_config_file_get)
Fastapi Config File Get
```
Get the settings as defined by the EOS configuration file.
Returns:
settings (SettingsEOS): The settings defined by the EOS configuration file.
```
**Responses**:
- **200**: Successful Response
---
## PUT /v1/config/file ## PUT /v1/config/file
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_file_put_v1_config_file_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_file_put_v1_config_file_put) **Links**: [local](http://localhost:8503/docs#/default/fastapi_config_file_put_v1_config_file_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_file_put_v1_config_file_put)
@@ -555,14 +238,14 @@ Returns:
--- ---
## POST /v1/config/update ## PUT /v1/config/reset
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_update_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_update_post_v1_config_update_post) **Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_reset_put), [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_reset_put)
Fastapi Config Update Post Fastapi Config Update Post
``` ```
Update the configuration from the EOS configuration file. Reset the configuration to the EOS configuration file.
Returns: Returns:
configuration (ConfigEOS): The current configuration after update. configuration (ConfigEOS): The current configuration after update.
@@ -574,37 +257,6 @@ Returns:
--- ---
## PUT /v1/config/value
**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 Value Put
```
Set the configuration option in the settings.
Args:
key (str): configuration key
value (Any): configuration value
Returns:
configuration (ConfigEOS): The current configuration after the write.
```
**Parameters**:
- `key` (query, required): configuration key
- `value` (query, required): configuration value
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## PUT /v1/measurement/data ## PUT /v1/measurement/data
**Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put) **Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put)
@@ -874,6 +526,31 @@ Args:
--- ---
## GET /v1/prediction/providers
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_providers_get_v1_prediction_providers_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_providers_get_v1_prediction_providers_get)
Fastapi Prediction Providers Get
```
Get a list of available prediction providers.
Args:
enabled (bool): Return enabled/disabled providers. If unset, return all providers.
```
**Parameters**:
- `enabled` (query, optional): No description provided.
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## GET /v1/prediction/series ## GET /v1/prediction/series
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_series_get_v1_prediction_series_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_series_get_v1_prediction_series_get) **Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_series_get_v1_prediction_series_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_series_get_v1_prediction_series_get)

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

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

View File

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

View File

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

View File

@@ -19,10 +19,14 @@ data is lost on re-start of the EOS REST server.
## Prediction Providers ## Prediction Providers
Most predictions can be sourced from various providers. The specific provider to use is configured Most predictions can be sourced from various providers. The specific provider to use is configured
in the EOS configuration. For example: in the EOS configuration and can be set by prediction type. For example:
```python ```python
weather_provider = "ClearOutside" {
"weather": {
"provider": "ClearOutside"
}
}
``` ```
Some providers offer multiple prediction keys. For instance, a weather provider might provide data Some providers offer multiple prediction keys. For instance, a weather provider might provide data
@@ -71,7 +75,7 @@ predictions are adjusted by real data from your system's measurements if given t
For example, the load prediction provider `LoadAkkudoktor` takes generic load data assembled by For example, the load prediction provider `LoadAkkudoktor` takes generic load data assembled by
Akkudoktor.net, maps that to the yearly energy consumption given in the configuration option Akkudoktor.net, maps that to the yearly energy consumption given in the configuration option
`loadakkudoktor_year_energy`, and finally adjusts the predicted load by the `measurement_loads` `loadakkudoktor_year_energy`, and finally adjusts the predicted load by the `loads`
of your system. of your system.
## Prediction Updates ## Prediction Updates
@@ -107,21 +111,23 @@ Prediction keys:
Configuration options: Configuration options:
- `elecprice_provider`: Electricity price provider id of provider to be used. - `elecprice`: Electricity price configuration.
- `provider`: Electricity price provider id of provider to be used.
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net. - `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
- `ElecPriceImport`: Imports from a file or JSON string. - `ElecPriceImport`: Imports from a file or JSON string.
- `elecprice_charges_kwh`: Electricity price charges (€/kWh). - `charges_kwh`: Electricity price charges (€/kWh).
- `elecpriceimport_file_path`: Path to the file to import electricity price forecast data from. - `provider_settings.import_file_path`: Path to the file to import electricity price forecast data from.
- `elecpriceimport_json`: JSON string, dictionary of electricity price forecast value lists. - `provider_settings.import_json`: JSON string, dictionary of electricity price forecast value lists.
### ElecPriceAkkudoktor Provider ### ElecPriceAkkudoktor Provider
The `ElecPriceAkkudoktor` provider retrieves electricity prices directly from **Akkudoktor.net**, The `ElecPriceAkkudoktor` provider retrieves electricity prices directly from **Akkudoktor.net**,
which supplies price data for the next 24 hours. For periods beyond 24 hours, the provider generates which supplies price data for the next 24 hours. For periods beyond 24 hours, the provider generates
prices by extrapolating historical price data combined with the most recent actual prices obtained prices by extrapolating historical price data combined with the most recent actual prices obtained
from Akkudoktor.net. Electricity price charges given in the `elecprice_charges_kwh` configuration from Akkudoktor.net. Electricity price charges given in the `charges_kwh` configuration
option are added. option are added.
### ElecPriceImport Provider ### ElecPriceImport Provider
@@ -136,7 +142,7 @@ The prediction key for the electricity price forecast data is:
The electricity proce forecast data must be provided in one of the formats described in The electricity proce forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source must be given in the <project:#prediction-import-providers>. The data source must be given in the
`elecpriceimport_file_path` or `elecpriceimport_json` configuration option. `import_file_path` or `import_json` configuration option.
## Load Prediction ## Load Prediction
@@ -148,14 +154,16 @@ Prediction keys:
Configuration options: Configuration options:
- `load_provider`: Load provider id of provider to be used. - `load`: Load configuration.
- `provider`: Load provider id of provider to be used.
- `LoadAkkudoktor`: Retrieves from local database. - `LoadAkkudoktor`: Retrieves from local database.
- `LoadImport`: Imports from a file or JSON string. - `LoadImport`: Imports from a file or JSON string.
- `loadakkudoktor_year_energy`: Yearly energy consumption (kWh). - `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
- `loadimport_file_path`: Path to the file to import load forecast data from. - `provider_settings.loadimport_file_path`: Path to the file to import load forecast data from.
- `loadimport_json`: JSON string, dictionary of load forecast value lists. - `provider_settings.loadimport_json`: JSON string, dictionary of load forecast value lists.
### LoadAkkudoktor Provider ### LoadAkkudoktor Provider
@@ -188,39 +196,44 @@ Prediction keys:
Configuration options: Configuration options:
- `pvforecast_provider`: PVForecast provider id of provider to be used. - `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.
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net. - `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
- `PVForecastImport`: Imports from a file or JSON string. - `PVForecastImport`: Imports from a file or JSON string.
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)" - `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°) - `planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking. - `planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - `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. - `planes[].pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW. - `planes[].mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `pvforecast<0..5>_pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. - `planes[].loss`: Sum of PV system losses in percent
- `pvforecast<0..5>_mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. - `planes[].trackingtype`: Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `pvforecast<0..5>_loss`: Sum of PV system losses in percent - `planes[].optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `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[].optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `pvforecast<0..5>_optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking. - `planes[].albedo`: Proportion of the light hitting the ground that it reflects back.
- `pvforecast<0..5>_optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. - `planes[].module_model`: Model of the PV modules of this plane.
- `pvforecast<0..5>_albedo`: Proportion of the light hitting the ground that it reflects back. - `planes[].inverter_model`: Model of the inverter of this plane.
- `pvforecast<0..5>_module_model`: Model of the PV modules of this plane. - `planes[].inverter_paco`: AC power rating of the inverter. [W]
- `pvforecast<0..5>_inverter_model`: Model of the inverter of this plane. - `planes[].modules_per_string`: Number of the PV modules of the strings of this plane.
- `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W] - `planes[].strings_per_inverter`: Number of the strings of the inverter of this plane.
- `pvforecast<0..5>_modules_per_string`: Number of the PV modules of the strings of this plane. - `provider_settings.import_file_path`: Path to the file to import PV forecast data from.
- `pvforecast<0..5>_strings_per_inverter`: Number of the strings of the inverter of this plane. - `provider_settings.import_json`: JSON string, dictionary of PV forecast value lists.
- `pvforecastimport_file_path`: Path to the file to import PV forecast data from.
- `pvforecastimport_json`: JSON string, dictionary of PV forecast value lists.
------ ------
Some of the configuration options directly follow the [PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en) nomenclature. Some of the planes configuration options directly follow the [PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en) nomenclature.
Detailed definitions taken from **PVGIS**: Detailed definitions taken from **PVGIS**:
- `pvforecast<0..5>_pvtechchoice` - `pvtechchoice`
The performance of PV modules depends on the temperature and on the solar irradiance, but the exact dependence varies between different types of PV modules. At the moment we can estimate the losses due to temperature and irradiance effects for the following types of modules: crystalline silicon cells; thin film modules made from CIS or CIGS and thin film modules made from Cadmium Telluride (CdTe). The performance of PV modules depends on the temperature and on the solar irradiance, but the exact dependence varies between different types of PV modules. At the moment we can estimate the losses due to temperature and irradiance effects for the following types of modules: crystalline silicon cells; thin film modules made from CIS or CIGS and thin film modules made from Cadmium Telluride (CdTe).
@@ -228,19 +241,19 @@ For other technologies (especially various amorphous technologies), this correct
PV power output also depends on the spectrum of the solar radiation. PVGIS can calculate how the variations of the spectrum of sunlight affects the overall energy production from a PV system. At the moment this calculation can be done for crystalline silicon and CdTe modules. Note that this calculation is not yet available when using the NSRDB solar radiation database. PV power output also depends on the spectrum of the solar radiation. PVGIS can calculate how the variations of the spectrum of sunlight affects the overall energy production from a PV system. At the moment this calculation can be done for crystalline silicon and CdTe modules. Note that this calculation is not yet available when using the NSRDB solar radiation database.
- `pvforecast<0..5>_peakpower` - `peakpower`
This is the power that the manufacturer declares that the PV array can produce under standard test conditions (STC), which are a constant 1000W of solar irradiation per square meter in the plane of the array, at an array temperature of 25°C. The peak power should be entered in kilowatt-peak (kWp). If you do not know the declared peak power of your modules but instead know the area of the modules and the declared conversion efficiency (in percent), you can calculate the peak power as power = area * efficiency / 100. This is the power that the manufacturer declares that the PV array can produce under standard test conditions (STC), which are a constant 1000W of solar irradiation per square meter in the plane of the array, at an array temperature of 25°C. The peak power should be entered in kilowatt-peak (kWp). If you do not know the declared peak power of your modules but instead know the area of the modules and the declared conversion efficiency (in percent), you can calculate the peak power as power = area * efficiency / 100.
Bifacial modules: PVGIS doesn't make specific calculations for bifacial modules at present. Users who wish to explore the possible benefits of this technology can input the power value for Bifacial Nameplate Irradiance. This can also be can also be estimated from the front side peak power P_STC value and the bifaciality factor, φ (if reported in the module data sheet) as: P_BNPI = P_STC * (1 + φ * 0.135). NB this bifacial approach is not appropriate for BAPV or BIPV installations or for modules mounting on a N-S axis i.e. facing E-W. Bifacial modules: PVGIS doesn't make specific calculations for bifacial modules at present. Users who wish to explore the possible benefits of this technology can input the power value for Bifacial Nameplate Irradiance. This can also be can also be estimated from the front side peak power P_STC value and the bifaciality factor, φ (if reported in the module data sheet) as: P_BNPI = P_STC * (1 + φ * 0.135). NB this bifacial approach is not appropriate for BAPV or BIPV installations or for modules mounting on a N-S axis i.e. facing E-W.
- `pvforecast<0..5>_loss` - `loss`
The estimated system losses are all the losses in the system, which cause the power actually delivered to the electricity grid to be lower than the power produced by the PV modules. There are several causes for this loss, such as losses in cables, power inverters, dirt (sometimes snow) on the modules and so on. Over the years the modules also tend to lose a bit of their power, so the average yearly output over the lifetime of the system will be a few percent lower than the output in the first years. The estimated system losses are all the losses in the system, which cause the power actually delivered to the electricity grid to be lower than the power produced by the PV modules. There are several causes for this loss, such as losses in cables, power inverters, dirt (sometimes snow) on the modules and so on. Over the years the modules also tend to lose a bit of their power, so the average yearly output over the lifetime of the system will be a few percent lower than the output in the first years.
We have given a default value of 14% for the overall losses. If you have a good idea that your value will be different (maybe due to a really high-efficiency inverter) you may reduce this value a little. We have given a default value of 14% for the overall losses. If you have a good idea that your value will be different (maybe due to a really high-efficiency inverter) you may reduce this value a little.
- `pvforecast<0..5>_mountingplace` - `mountingplace`
For fixed (non-tracking) systems, the way the modules are mounted will have an influence on the temperature of the module, which in turn affects the efficiency. Experiments have shown that if the movement of air behind the modules is restricted, the modules can get considerably hotter (up to 15°C at 1000W/m2 of sunlight). For fixed (non-tracking) systems, the way the modules are mounted will have an influence on the temperature of the module, which in turn affects the efficiency. Experiments have shown that if the movement of air behind the modules is restricted, the modules can get considerably hotter (up to 15°C at 1000W/m2 of sunlight).
@@ -248,7 +261,7 @@ In PVGIS there are two possibilities: free-standing, meaning that the modules ar
Some types of mounting are in between these two extremes, for instance if the modules are mounted on a roof with curved roof tiles, allowing air to move behind the modules. In such cases, the performance will be somewhere between the results of the two calculations that are possible here. Some types of mounting are in between these two extremes, for instance if the modules are mounted on a roof with curved roof tiles, allowing air to move behind the modules. In such cases, the performance will be somewhere between the results of the two calculations that are possible here.
- `pvforecast<0..5>_userhorizon` - `userhorizon`
Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. In the user horizon Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. In the user horizon
data each number represents the horizon height in degrees in a certain compass direction around the data each number represents the horizon height in degrees in a certain compass direction around the
@@ -260,15 +273,15 @@ degrees west of north.
------ ------
Most of the configuration options are in line with the [PVLib](https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html) definition for PVGIS data. Most of the planes configuration options are in line with the [PVLib](https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html) definition for PVGIS data.
Detailed definitions from **PVLib** for PVGIS data. Detailed definitions from **PVLib** for PVGIS data.
- `pvforecast<0..5>_surface_tilt`: - `surface_tilt`:
Tilt angle from horizontal plane. Tilt angle from horizontal plane.
- `pvforecast<0..5>_surface_azimuth` - `surface_azimuth`
Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180,
west=270). This is offset 180 degrees from the convention used by PVGIS. west=270). This is offset 180 degrees from the convention used by PVGIS.
@@ -280,47 +293,60 @@ west=270). This is offset 180 degrees from the convention used by PVGIS.
The `PVForecastAkkudoktor` provider retrieves the PV power forecast data directly from The `PVForecastAkkudoktor` provider retrieves the PV power forecast data directly from
**Akkudoktor.net**. **Akkudoktor.net**.
The following general configuration options of the PV system must be set: The following prediction configuration options of the PV system must be set:
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)" - `general.latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°) - `general.longitude`: Longitude in decimal degrees, within -180 to 180 (°)
For each plane `<0..5>` of the PV system the following configuration options must be set: For each plane of the PV system the following configuration options must be set:
- `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking. - `pvforecast.planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - `pvforecast.planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast<0..5>_userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. - `pvforecast.planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W] - `pvforecast.planes[].inverter_paco`: AC power rating of the inverter. [W]
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW. - `pvforecast.planes[].peakpower`: Nominal power of PV system in kW.
Example: Example:
```Python ```Python
{ {
"general": {
"latitude": 50.1234, "latitude": 50.1234,
"longitude": 9.7654, "longitude": 9.7654,
"pvforecast_provider": "PVForecastAkkudoktor", },
"pvforecast0_peakpower": 5.0, "pvforecast": {
"pvforecast0_surface_azimuth": -10, "provider": "PVForecastAkkudoktor",
"pvforecast0_surface_tilt": 7, "planes": [
"pvforecast0_userhorizon": [20, 27, 22, 20], {
"pvforecast0_inverter_paco": 10000, "peakpower": 5.0,
"pvforecast1_peakpower": 4.8, "surface_azimuth": -10,
"pvforecast1_surface_azimuth": -90, "surface_tilt": 7,
"pvforecast1_surface_tilt": 7, "userhorizon": [20, 27, 22, 20],
"pvforecast1_userhorizon": [30, 30, 30, 50], "inverter_paco": 10000,
"pvforecast1_inverter_paco": 10000, },
"pvforecast2_peakpower": 1.4, {
"pvforecast2_surface_azimuth": -40, "peakpower": 4.8,
"pvforecast2_surface_tilt": 60, "surface_azimuth": -90,
"pvforecast2_userhorizon": [60, 30, 0, 30], "surface_tilt": 7,
"pvforecast2_inverter_paco": 2000, "userhorizon": [30, 30, 30, 50],
"pvforecast3_peakpower": 1.6, "inverter_paco": 10000,
"pvforecast3_surface_azimuth": 5, },
"pvforecast3_surface_tilt": 45, {
"pvforecast3_userhorizon": [45, 25, 30, 60], "peakpower": 1.4,
"pvforecast3_inverter_paco": 1400, "surface_azimuth": -40,
"pvforecast4_peakpower": None, "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,
}
]
}
} }
``` ```
@@ -337,7 +363,7 @@ The prediction keys for the PV forecast data are:
The PV forecast data must be provided in one of the formats described in The PV forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source must be given in the <project:#prediction-import-providers>. The data source must be given in the
`pvforecastimport_file_path` or `pvforecastimport_json` configuration option. `import_file_path` or `import_json` configuration option.
## Weather Prediction ## Weather Prediction
@@ -368,14 +394,16 @@ Prediction keys:
Configuration options: Configuration options:
- `weather_provider`: Load provider id of provider to be used. - `weather`: General weather configuration.
- `provider`: Load provider id of provider to be used.
- `BrightSky`: Retrieves from https://api.brightsky.dev. - `BrightSky`: Retrieves from https://api.brightsky.dev.
- `ClearOutside`: Retrieves from https://clearoutside.com/forecast. - `ClearOutside`: Retrieves from https://clearoutside.com/forecast.
- `LoadImport`: Imports from a file or JSON string. - `LoadImport`: Imports from a file or JSON string.
- `weatherimport_file_path`: Path to the file to import weatherforecast data from. - `provider_settings.import_file_path`: Path to the file to import weatherforecast data from.
- `weatherimport_json`: JSON string, dictionary of weather forecast value lists. - `provider_settings.import_json`: JSON string, dictionary of weather forecast value lists.
### BrightSky Provider ### BrightSky Provider
@@ -459,4 +487,4 @@ The prediction keys for the PV forecast data are:
The PV forecast data must be provided in one of the formats described in The PV forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source must be given in the <project:#prediction-import-providers>. The data source must be given in the
`weatherimport_file_path` or `pvforecastimport_json` configuration option. `import_file_path` or `import_json` configuration option.

View File

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

11651
openapi.json

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,17 @@
numpy==2.2.2 numpy==2.2.2
numpydantic==1.6.4 numpydantic==1.6.7
matplotlib==3.10.0 matplotlib==3.10.0
fastapi[standard]==0.115.6 fastapi[standard]==0.115.7
python-fasthtml==0.12.0 python-fasthtml==0.12.0
uvicorn==0.34.0 uvicorn==0.34.0
scikit-learn==1.6.1 scikit-learn==1.6.1
timezonefinder==6.5.7 timezonefinder==6.5.8
deap==1.4.2 deap==1.4.2
requests==2.32.3 requests==2.32.3
pandas==2.2.3 pandas==2.2.3
pendulum==3.0.0 pendulum==3.0.0
platformdirs==4.3.6 platformdirs==4.3.6
pvlib==0.11.2 pvlib==0.11.2
pydantic==2.10.5 pydantic==2.10.6
statsmodels==0.14.4 statsmodels==0.14.4
pydantic-settings==2.7.0

View File

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

View File

@@ -37,6 +37,11 @@ def generate_openapi() -> dict:
routes=app.routes, routes=app.routes,
) )
# Fix file path for general settings to not show local/test file path
general = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["general"]["default"]
general["config_file_path"] = "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
general["config_folder_path"] = "/home/user/.config/net.akkudoktoreos.net"
return openapi_spec return openapi_spec

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,6 @@ from akkudoktoreos.core.pydantic import PydanticBaseModel
class SettingsBaseModel(PydanticBaseModel): class SettingsBaseModel(PydanticBaseModel):
"""Base model class for all settings configurations. """Base model class for all settings configurations."""
Note:
Settings property names shall be disjunctive to all existing settings' property names.
"""
pass pass

View File

@@ -265,6 +265,12 @@ class SingletonMixin:
class MySingletonModel(SingletonMixin, PydanticBaseModel): class MySingletonModel(SingletonMixin, PydanticBaseModel):
name: str name: str
# implement __init__ to avoid re-initialization of parent class PydanticBaseModel:
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
instance1 = MySingletonModel(name="Instance 1") instance1 = MySingletonModel(name="Instance 1")
instance2 = MySingletonModel(name="Instance 2") instance2 = MySingletonModel(name="Instance 2")

View File

@@ -1110,7 +1110,7 @@ class DataProvider(SingletonMixin, DataSequence):
To be implemented by derived classes. To be implemented by derived classes.
""" """
return self.provider_id() == self.config.abstract_provider raise NotImplementedError()
@abstractmethod @abstractmethod
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
@@ -1121,6 +1121,11 @@ class DataProvider(SingletonMixin, DataSequence):
""" """
pass pass
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def update_data( def update_data(
self, self,
force_enable: Optional[bool] = False, force_enable: Optional[bool] = False,
@@ -1595,6 +1600,11 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
) )
return list(key_set) return list(key_set)
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def __getitem__(self, key: str) -> pd.Series: def __getitem__(self, key: str) -> pd.Series:
"""Retrieve a Pandas Series for a specified key from the data in each DataProvider. """Retrieve a Pandas Series for a specified key from the data in each DataProvider.

View File

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

View File

@@ -1,4 +1,4 @@
from typing import Any, ClassVar, Dict, Optional, Union from typing import Any, ClassVar, Optional
import numpy as np import numpy as np
from numpydantic import NDArray, Shape from numpydantic import NDArray, Shape
@@ -169,6 +169,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD") dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD") ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
def set_parameters( def set_parameters(
self, self,
parameters: EnergieManagementSystemParameters, parameters: EnergieManagementSystemParameters,
@@ -186,19 +191,19 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
len(self.load_energy_array), parameters.einspeiseverguetung_euro_pro_wh, float len(self.load_energy_array), parameters.einspeiseverguetung_euro_pro_wh, float
) )
) )
if inverter is not None: if inverter:
self.battery = inverter.battery self.battery = inverter.battery
else: else:
self.battery = None self.battery = None
self.ev = ev self.ev = ev
self.home_appliance = home_appliance self.home_appliance = home_appliance
self.inverter = inverter self.inverter = inverter
self.ac_charge_hours = np.full(self.config.prediction_hours, 0.0) self.ac_charge_hours = np.full(self.config.prediction.hours, 0.0)
self.dc_charge_hours = np.full(self.config.prediction_hours, 1.0) self.dc_charge_hours = np.full(self.config.prediction.hours, 1.0)
self.ev_charge_hours = np.full(self.config.prediction_hours, 0.0) self.ev_charge_hours = np.full(self.config.prediction.hours, 0.0)
def set_akku_discharge_hours(self, ds: np.ndarray) -> None: def set_akku_discharge_hours(self, ds: np.ndarray) -> None:
if self.battery is not None: if self.battery:
self.battery.set_discharge_per_hour(ds) self.battery.set_discharge_per_hour(ds)
def set_akku_ac_charge_hours(self, ds: np.ndarray) -> None: def set_akku_ac_charge_hours(self, ds: np.ndarray) -> None:
@@ -211,7 +216,7 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
self.ev_charge_hours = ds self.ev_charge_hours = ds
def set_home_appliance_start(self, ds: int, global_start_hour: int = 0) -> None: def set_home_appliance_start(self, ds: int, global_start_hour: int = 0) -> None:
if self.home_appliance is not None: if self.home_appliance:
self.home_appliance.set_starting_time(ds, global_start_hour=global_start_hour) self.home_appliance.set_starting_time(ds, global_start_hour=global_start_hour)
def reset(self) -> None: def reset(self) -> None:
@@ -246,11 +251,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
error_msg = "Start datetime unknown." error_msg = "Start datetime unknown."
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
if self.config.prediction_hours is None: if self.config.prediction.hours is None:
error_msg = "Prediction hours unknown." error_msg = "Prediction hours unknown."
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
if self.config.optimisation_hours is None: if self.config.prediction.optimisation_hours is None:
error_msg = "Optimisation hours unknown." error_msg = "Optimisation hours unknown."
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
@@ -276,53 +281,50 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
return self.simulate(start_hour) return self.simulate(start_hour)
def simulate(self, start_hour: int) -> dict[str, Any]: def simulate(self, start_hour: int) -> dict[str, Any]:
"""hour. """Simulate energy usage and costs for the given start hour.
akku_soc_pro_stunde begin of the hour, initial hour state! akku_soc_pro_stunde begin of the hour, initial hour state!
last_wh_pro_stunde integral of last hour (end state) last_wh_pro_stunde integral of last hour (end state)
""" """
# Check for simulation integrity # Check for simulation integrity
missing_data = [] required_attrs = [
"load_energy_array",
if self.load_energy_array is None: "pv_prediction_wh",
missing_data.append("Load Curve") "elect_price_hourly",
if self.pv_prediction_wh is None: "ev_charge_hours",
missing_data.append("PV Forecast") "ac_charge_hours",
if self.elect_price_hourly is None: "dc_charge_hours",
missing_data.append("Electricity Price") "elect_revenue_per_hour_arr",
if self.ev_charge_hours is None: ]
missing_data.append("EV Charge Hours") missing_data = [
if self.ac_charge_hours is None: attr.replace("_", " ").title() for attr in required_attrs if getattr(self, attr) is None
missing_data.append("AC Charge Hours") ]
if self.dc_charge_hours is None:
missing_data.append("DC Charge Hours")
if self.elect_revenue_per_hour_arr is None:
missing_data.append("Feed-in Tariff")
if missing_data: if missing_data:
error_msg = "Mandatory data missing - " + ", ".join(missing_data) logger.error("Mandatory data missing - %s", ", ".join(missing_data))
logger.error(error_msg) raise ValueError(f"Mandatory data missing: {', '.join(missing_data)}")
raise ValueError(error_msg)
else:
# make mypy happy
assert self.load_energy_array is not None
assert self.pv_prediction_wh is not None
assert self.elect_price_hourly is not None
assert self.ev_charge_hours is not None
assert self.ac_charge_hours is not None
assert self.dc_charge_hours is not None
assert self.elect_revenue_per_hour_arr is not None
load_energy_array = self.load_energy_array # Pre-fetch data
load_energy_array = np.array(self.load_energy_array)
pv_prediction_wh = np.array(self.pv_prediction_wh)
elect_price_hourly = np.array(self.elect_price_hourly)
ev_charge_hours = np.array(self.ev_charge_hours)
ac_charge_hours = np.array(self.ac_charge_hours)
dc_charge_hours = np.array(self.dc_charge_hours)
elect_revenue_per_hour_arr = np.array(self.elect_revenue_per_hour_arr)
if not ( # Fetch objects
len(load_energy_array) == len(self.pv_prediction_wh) == len(self.elect_price_hourly) battery = self.battery
): assert battery # to please mypy
error_msg = f"Array sizes do not match: Load Curve = {len(load_energy_array)}, PV Forecast = {len(self.pv_prediction_wh)}, Electricity Price = {len(self.elect_price_hourly)}" ev = self.ev
home_appliance = self.home_appliance
inverter = self.inverter
if not (len(load_energy_array) == len(pv_prediction_wh) == len(elect_price_hourly)):
error_msg = f"Array sizes do not match: Load Curve = {len(load_energy_array)}, PV Forecast = {len(pv_prediction_wh)}, Electricity Price = {len(elect_price_hourly)}"
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
# Optimized total hours calculation
end_hour = len(load_energy_array) end_hour = len(load_energy_array)
total_hours = end_hour - start_hour total_hours = end_hour - start_hour
@@ -332,116 +334,110 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
consumption_energy_per_hour = np.full((total_hours), np.nan) consumption_energy_per_hour = np.full((total_hours), np.nan)
costs_per_hour = np.full((total_hours), np.nan) costs_per_hour = np.full((total_hours), np.nan)
revenue_per_hour = np.full((total_hours), np.nan) revenue_per_hour = np.full((total_hours), np.nan)
soc_per_hour = np.full((total_hours), np.nan) # Hour End State soc_per_hour = np.full((total_hours), np.nan)
soc_ev_per_hour = np.full((total_hours), np.nan) soc_ev_per_hour = np.full((total_hours), np.nan)
losses_wh_per_hour = np.full((total_hours), np.nan) losses_wh_per_hour = np.full((total_hours), np.nan)
home_appliance_wh_per_hour = np.full((total_hours), np.nan) home_appliance_wh_per_hour = np.full((total_hours), np.nan)
electricity_price_per_hour = np.full((total_hours), np.nan) electricity_price_per_hour = np.full((total_hours), np.nan)
# Set initial state # Set initial state
if self.battery: soc_per_hour[0] = battery.current_soc_percentage()
soc_per_hour[0] = self.battery.current_soc_percentage() if ev:
if self.ev: soc_ev_per_hour[0] = ev.current_soc_percentage()
soc_ev_per_hour[0] = self.ev.current_soc_percentage()
for hour in range(start_hour, end_hour): for hour in range(start_hour, end_hour):
hour_since_now = hour - start_hour hour_idx = hour - start_hour
# save begin states # save begin states
if self.battery: soc_per_hour[hour_idx] = battery.current_soc_percentage()
soc_per_hour[hour_since_now] = self.battery.current_soc_percentage()
else: if ev:
soc_per_hour[hour_since_now] = 0.0 soc_ev_per_hour[hour_idx] = ev.current_soc_percentage()
if self.ev:
soc_ev_per_hour[hour_since_now] = self.ev.current_soc_percentage()
# Accumulate loads and PV generation # Accumulate loads and PV generation
consumption = self.load_energy_array[hour] consumption = load_energy_array[hour]
losses_wh_per_hour[hour_since_now] = 0.0 losses_wh_per_hour[hour_idx] = 0.0
# Home appliances # Home appliances
if self.home_appliance: if home_appliance:
ha_load = self.home_appliance.get_load_for_hour(hour) ha_load = home_appliance.get_load_for_hour(hour)
consumption += ha_load consumption += ha_load
home_appliance_wh_per_hour[hour_since_now] = ha_load home_appliance_wh_per_hour[hour_idx] = ha_load
# E-Auto handling # E-Auto handling
if self.ev: if ev and ev_charge_hours[hour] > 0:
if self.ev_charge_hours[hour] > 0: loaded_energy_ev, verluste_eauto = ev.charge_energy(
loaded_energy_ev, verluste_eauto = self.ev.charge_energy( None, hour, relative_power=ev_charge_hours[hour]
None, hour, relative_power=self.ev_charge_hours[hour]
) )
consumption += loaded_energy_ev consumption += loaded_energy_ev
losses_wh_per_hour[hour_since_now] += verluste_eauto losses_wh_per_hour[hour_idx] += verluste_eauto
# Process inverter logic # Process inverter logic
energy_feedin_grid_actual, energy_consumption_grid_actual, losses, eigenverbrauch = ( energy_feedin_grid_actual = energy_consumption_grid_actual = losses = eigenverbrauch = (
0.0, 0.0
0.0,
0.0,
0.0,
) )
if self.battery:
self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour) hour_ac_charge = ac_charge_hours[hour]
if self.inverter: hour_dc_charge = dc_charge_hours[hour]
energy_produced = self.pv_prediction_wh[hour] hourly_electricity_price = elect_price_hourly[hour]
hourly_energy_revenue = elect_revenue_per_hour_arr[hour]
battery.set_charge_allowed_for_hour(hour_dc_charge, hour)
if inverter:
energy_produced = pv_prediction_wh[hour]
( (
energy_feedin_grid_actual, energy_feedin_grid_actual,
energy_consumption_grid_actual, energy_consumption_grid_actual,
losses, losses,
eigenverbrauch, eigenverbrauch,
) = self.inverter.process_energy(energy_produced, consumption, hour) ) = inverter.process_energy(energy_produced, consumption, hour)
# AC PV Battery Charge # AC PV Battery Charge
if self.battery and self.ac_charge_hours[hour] > 0.0: if hour_ac_charge > 0.0:
self.battery.set_charge_allowed_for_hour(1, hour) battery.set_charge_allowed_for_hour(1, hour)
battery_charged_energy_actual, battery_losses_actual = self.battery.charge_energy( battery_charged_energy_actual, battery_losses_actual = battery.charge_energy(
None, hour, relative_power=self.ac_charge_hours[hour] None, hour, relative_power=hour_ac_charge
) )
# print(hour, " ", battery_charged_energy_actual, " ",self.ac_charge_hours[hour]," ",self.battery.current_soc_percentage())
consumption += battery_charged_energy_actual
consumption += battery_losses_actual
energy_consumption_grid_actual += battery_charged_energy_actual
energy_consumption_grid_actual += battery_losses_actual
losses_wh_per_hour[hour_since_now] += battery_losses_actual
feedin_energy_per_hour[hour_since_now] = energy_feedin_grid_actual total_battery_energy = battery_charged_energy_actual + battery_losses_actual
consumption_energy_per_hour[hour_since_now] = energy_consumption_grid_actual consumption += total_battery_energy
losses_wh_per_hour[hour_since_now] += losses energy_consumption_grid_actual += total_battery_energy
loads_energy_per_hour[hour_since_now] = consumption losses_wh_per_hour[hour_idx] += battery_losses_actual
electricity_price_per_hour[hour_since_now] = self.elect_price_hourly[hour]
# Update hourly arrays
feedin_energy_per_hour[hour_idx] = energy_feedin_grid_actual
consumption_energy_per_hour[hour_idx] = energy_consumption_grid_actual
losses_wh_per_hour[hour_idx] += losses
loads_energy_per_hour[hour_idx] = consumption
electricity_price_per_hour[hour_idx] = hourly_electricity_price
# Financial calculations # Financial calculations
costs_per_hour[hour_since_now] = ( costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price
energy_consumption_grid_actual * self.elect_price_hourly[hour] revenue_per_hour[hour_idx] = energy_feedin_grid_actual * hourly_energy_revenue
)
revenue_per_hour[hour_since_now] = (
energy_feedin_grid_actual * self.elect_revenue_per_hour_arr[hour]
)
# Total cost and return total_cost = np.nansum(costs_per_hour)
gesamtkosten_euro = np.nansum(costs_per_hour) - np.nansum(revenue_per_hour) total_losses = np.nansum(losses_wh_per_hour)
total_revenue = np.nansum(revenue_per_hour)
# Prepare output dictionary # Prepare output dictionary
out: Dict[str, Union[np.ndarray, float]] = { return {
"Last_Wh_pro_Stunde": loads_energy_per_hour, "Last_Wh_pro_Stunde": loads_energy_per_hour,
"Netzeinspeisung_Wh_pro_Stunde": feedin_energy_per_hour, "Netzeinspeisung_Wh_pro_Stunde": feedin_energy_per_hour,
"Netzbezug_Wh_pro_Stunde": consumption_energy_per_hour, "Netzbezug_Wh_pro_Stunde": consumption_energy_per_hour,
"Kosten_Euro_pro_Stunde": costs_per_hour, "Kosten_Euro_pro_Stunde": costs_per_hour,
"akku_soc_pro_stunde": soc_per_hour, "akku_soc_pro_stunde": soc_per_hour,
"Einnahmen_Euro_pro_Stunde": revenue_per_hour, "Einnahmen_Euro_pro_Stunde": revenue_per_hour,
"Gesamtbilanz_Euro": gesamtkosten_euro, "Gesamtbilanz_Euro": total_cost - total_revenue,
"EAuto_SoC_pro_Stunde": soc_ev_per_hour, "EAuto_SoC_pro_Stunde": soc_ev_per_hour,
"Gesamteinnahmen_Euro": np.nansum(revenue_per_hour), "Gesamteinnahmen_Euro": total_revenue,
"Gesamtkosten_Euro": np.nansum(costs_per_hour), "Gesamtkosten_Euro": total_cost,
"Verluste_Pro_Stunde": losses_wh_per_hour, "Verluste_Pro_Stunde": losses_wh_per_hour,
"Gesamt_Verluste": np.nansum(losses_wh_per_hour), "Gesamt_Verluste": total_losses,
"Home_appliance_wh_per_hour": home_appliance_wh_per_hour, "Home_appliance_wh_per_hour": home_appliance_wh_per_hour,
"Electricity_price": electricity_price_per_hour, "Electricity_price": electricity_price_per_hour,
} }
return out
# Initialize the Energy Management System, it is a singleton. # Initialize the Energy Management System, it is a singleton.
ems = EnergieManagementSystem() ems = EnergieManagementSystem()

View File

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

View File

@@ -14,6 +14,7 @@ Key Features:
import json import json
import re import re
from copy import deepcopy
from typing import Any, Dict, List, Optional, Type, Union from typing import Any, Dict, List, Optional, Type, Union
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -35,6 +36,21 @@ from pydantic import (
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]:
def deep_update(source_dict: dict[str, Any], update_dict: dict[str, Any]) -> dict[str, Any]:
for key, value in source_dict.items():
if isinstance(value, dict) and isinstance(update_dict.get(key), dict):
update_dict[key] = deep_update(update_dict[key], value)
else:
update_dict[key] = value
return update_dict
source_dict = source.model_dump(exclude_unset=True)
merged_dict = deep_update(source_dict, deepcopy(update_dict))
return merged_dict
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]): class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
"""Custom type adapter for Pendulum DateTime fields.""" """Custom type adapter for Pendulum DateTime fields."""
@@ -113,9 +129,16 @@ class PydanticBaseModel(BaseModel):
return value return value
# Override Pydantics serialization for all DateTime fields # Override Pydantics serialization for all DateTime fields
def model_dump(self, *args: Any, **kwargs: Any) -> dict: def model_dump(
self, *args: Any, include_computed_fields: bool = True, **kwargs: Any
) -> dict[str, Any]:
"""Custom dump method to handle serialization for DateTime fields.""" """Custom dump method to handle serialization for DateTime fields."""
result = super().model_dump(*args, **kwargs) result = super().model_dump(*args, **kwargs)
if not include_computed_fields:
for computed_field_name in self.model_computed_fields:
result.pop(computed_field_name, None)
for key, value in result.items(): for key, value in result.items():
if isinstance(value, pendulum.DateTime): if isinstance(value, pendulum.DateTime):
result[key] = PydanticTypeAdapterDateTime.serialize(value) result[key] = PydanticTypeAdapterDateTime.serialize(value)
@@ -170,6 +193,10 @@ class PydanticBaseModel(BaseModel):
""" """
return cls.model_validate(data) return cls.model_validate(data)
def model_dump_json(self, *args: Any, indent: Optional[int] = None, **kwargs: Any) -> str:
data = self.model_dump(*args, **kwargs)
return json.dumps(data, indent=indent, default=str)
def to_json(self) -> str: def to_json(self) -> str:
"""Convert the PydanticBaseModel instance to a JSON string. """Convert the PydanticBaseModel instance to a JSON string.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,9 +18,9 @@ class Heatpump:
COP_COEFFICIENT = 0.1 COP_COEFFICIENT = 0.1
"""COP increase per degree""" """COP increase per degree"""
def __init__(self, max_heat_output: int, prediction_hours: int): def __init__(self, max_heat_output: int, hours: int):
self.max_heat_output = max_heat_output self.max_heat_output = max_heat_output
self.prediction_hours = prediction_hours self.hours = hours
self.log = logging.getLogger(__name__) self.log = logging.getLogger(__name__)
def __check_outside_temperature_range__(self, temp_celsius: float) -> bool: def __check_outside_temperature_range__(self, temp_celsius: float) -> bool:
@@ -117,9 +117,9 @@ class Heatpump:
"""Simulate power data for 24 hours based on provided temperatures.""" """Simulate power data for 24 hours based on provided temperatures."""
power_data: List[float] = [] power_data: List[float] = []
if len(temperatures) != self.prediction_hours: if len(temperatures) != self.hours:
raise ValueError( raise ValueError(
f"The temperature array must contain exactly {self.prediction_hours} entries, " f"The temperature array must contain exactly {self.hours} entries, "
"one for each hour of the day." "one for each hour of the day."
) )

View File

@@ -1,64 +1,48 @@
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field
from scipy.interpolate import RegularGridInterpolator
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
from akkudoktoreos.devices.battery import Battery from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
from akkudoktoreos.devices.devicesabc import DeviceBase
logger = get_logger(__name__) logger = get_logger(__name__)
class InverterParameters(ParametersBaseModel): class InverterParameters(DeviceParameters):
max_power_wh: float = Field(gt=0) """Inverter Device Simulation Configuration."""
device_id: str = Field(description="ID of inverter", examples=["inverter1"])
max_power_wh: float = Field(gt=0, examples=[10000])
battery_id: Optional[str] = Field(
default=None, description="ID of battery", examples=[None, "battery1"]
)
class Inverter(DeviceBase): class Inverter(DeviceBase):
def __init__( def __init__(
self, self,
self_consumption_predictor: RegularGridInterpolator,
parameters: Optional[InverterParameters] = None, parameters: Optional[InverterParameters] = None,
battery: Optional[Battery] = None,
provider_id: Optional[str] = None,
): ):
# Configuration initialisation self.parameters: Optional[InverterParameters] = None
self.provider_id = provider_id super().__init__(parameters)
self.prefix = "<invalid>"
if self.provider_id == "GenericInverter": def _setup(self) -> None:
self.prefix = "inverter" assert self.parameters is not None
# Parameter initialisiation if self.parameters.battery_id is None:
self.parameters = parameters
if battery is None:
# For the moment raise exception # For the moment raise exception
# TODO: Make battery configurable by config # TODO: Make battery configurable by config
error_msg = "Battery for PV inverter is mandatory." error_msg = "Battery for PV inverter is mandatory."
logger.error(error_msg) logger.error(error_msg)
raise NotImplementedError(error_msg) raise NotImplementedError(error_msg)
self.battery = battery # Connection to a battery object self.self_consumption_predictor = get_eos_load_interpolator()
self.self_consumption_predictor = self_consumption_predictor
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.max_power_wh = (
self.parameters.max_power_wh # Maximum power that the inverter can handle self.parameters.max_power_wh
) ) # Maximum power that the inverter can handle
else:
error_msg = "Parameters and provider ID missing. Can't instantiate." def _post_setup(self) -> None:
logger.error(error_msg) assert self.parameters is not None
raise ValueError(error_msg) self.battery = self.devices.get_device_by_id(self.parameters.battery_id)
def process_energy( def process_energy(
self, generation: float, consumption: float, hour: int self, generation: float, consumption: float, hour: int

View File

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

View File

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

View File

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

View File

@@ -9,21 +9,19 @@ logger = get_logger(__name__)
class OptimizationCommonSettings(SettingsBaseModel): class OptimizationCommonSettings(SettingsBaseModel):
"""Base configuration for optimization settings. """General Optimization Configuration.
Attributes: Attributes:
optimization_hours (int): Number of hours for optimizations. hours (int): Number of hours for optimizations.
""" """
optimization_hours: Optional[int] = Field( hours: Optional[int] = Field(
default=24, ge=0, description="Number of hours into the future for optimizations." default=48, ge=0, description="Number of hours into the future for optimizations."
) )
optimization_penalty: Optional[int] = Field( penalty: Optional[int] = Field(default=10, description="Penalty factor used in optimization.")
default=10, description="Penalty factor used in optimization."
)
optimization_ev_available_charge_rates_percent: Optional[List[float]] = Field( ev_available_charge_rates_percent: Optional[List[float]] = Field(
default=[ default=[
0.0, 0.0,
6.0 / 16.0, 6.0 / 16.0,

View File

@@ -3,12 +3,21 @@ from typing import Optional
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
class ElecPriceCommonSettings(SettingsBaseModel): class ElecPriceCommonSettings(SettingsBaseModel):
elecprice_provider: Optional[str] = Field( """Electricity Price Prediction Configuration."""
default=None, description="Electricity price provider id of provider to be used."
provider: Optional[str] = Field(
default=None,
description="Electricity price provider id of provider to be used.",
examples=["ElecPriceAkkudoktor"],
) )
elecprice_charges_kwh: Optional[float] = Field( charges_kwh: Optional[float] = Field(
default=None, ge=0, description="Electricity price charges (€/kWh)." default=None, ge=0, description="Electricity price charges (€/kWh).", examples=[0.21]
)
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
) )

View File

@@ -49,15 +49,15 @@ class ElecPriceProvider(PredictionProvider):
electricity price_provider (str): Prediction provider for electricity price. electricity price_provider (str): Prediction provider for electricity price.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range, end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
# overload # overload
@@ -71,4 +71,4 @@ class ElecPriceProvider(PredictionProvider):
return "ElecPriceProvider" return "ElecPriceProvider"
def enabled(self) -> bool: def enabled(self) -> bool:
return self.provider_id() == self.config.elecprice_provider return self.provider_id() == self.config.elecprice.provider

View File

@@ -54,11 +54,11 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
prediction_hours (int, optional): Number of hours in the future for the forecast. hours (int, optional): Number of hours in the future for the forecast.
prediction_historic_hours (int, optional): Number of past hours for retaining data. historic_hours (int, optional): Number of past hours for retaining data.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime. start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -108,13 +108,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
# Try to take data from 5 weeks back for prediction # Try to take data from 5 weeks back for prediction
date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD") date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}" url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.general.timezone}"
response = requests.get(url) response = requests.get(url)
logger.debug(f"Response from {url}: {response}") logger.debug(f"Response from {url}: {response}")
response.raise_for_status() # Raise an error for bad responses response.raise_for_status() # Raise an error for bad responses
akkudoktor_data = self._validate_data(response.content) akkudoktor_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return akkudoktor_data return akkudoktor_data
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray: def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
@@ -125,18 +125,16 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
capped_data = data.clip(min=lower_bound, max=upper_bound) capped_data = data.clip(min=lower_bound, max=upper_bound)
return capped_data return capped_data
def _predict_ets( def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
self, history: np.ndarray, seasonal_periods: int, prediction_hours: int
) -> np.ndarray:
clean_history = self._cap_outliers(history) clean_history = self._cap_outliers(history)
model = ExponentialSmoothing( model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit() ).fit()
return model.forecast(prediction_hours) return model.forecast(hours)
def _predict_median(self, history: np.ndarray, prediction_hours: int) -> np.ndarray: def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history) clean_history = self._cap_outliers(history)
return np.full(prediction_hours, np.median(clean_history)) return np.full(hours, np.median(clean_history))
def _update_data( def _update_data(
self, force_update: Optional[bool] = False self, force_update: Optional[bool] = False
@@ -155,14 +153,14 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
# Assumption that all lists are the same length and are ordered chronologically # Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps. # in ascending order and have the same timestamps.
# Get elecprice_charges_kwh in wh # Get charges_kwh in wh
charges_wh = (self.config.elecprice_charges_kwh or 0) / 1000 charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
highest_orig_datetime = None # newest datetime from the api after that we want to update. highest_orig_datetime = None # newest datetime from the api after that we want to update.
series_data = pd.Series(dtype=float) # Initialize an empty series series_data = pd.Series(dtype=float) # Initialize an empty series
for value in akkudoktor_data.values: for value in akkudoktor_data.values:
orig_datetime = to_datetime(value.start, in_timezone=self.config.timezone) orig_datetime = to_datetime(value.start, in_timezone=self.config.general.timezone)
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime: if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
highest_orig_datetime = orig_datetime highest_orig_datetime = orig_datetime
@@ -183,27 +181,23 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
assert highest_orig_datetime # mypy fix assert highest_orig_datetime # mypy fix
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
needed_prediction_hours = int( needed_hours = int(
self.config.prediction_hours self.config.prediction.hours
- ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600) - ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
) )
if needed_prediction_hours <= 0: if needed_hours <= 0:
logger.warning( logger.warning(
f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}" f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}"
) # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records ) # this might keep data longer than self.start_datetime + self.config.prediction.hours in the records
return return
if amount_datasets > 800: # we do the full ets with seasons of 1 week if amount_datasets > 800: # we do the full ets with seasons of 1 week
prediction = self._predict_ets( prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
history, seasonal_periods=168, prediction_hours=needed_prediction_hours
)
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
prediction = self._predict_ets( prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
history, seasonal_periods=24, prediction_hours=needed_prediction_hours
)
elif amount_datasets > 0: # not enough data for ets, do median elif amount_datasets > 0: # not enough data for ets, do median
prediction = self._predict_median(history, prediction_hours=needed_prediction_hours) prediction = self._predict_median(history, hours=needed_hours)
else: else:
logger.error("No data available for prediction") logger.error("No data available for prediction")
raise ValueError("No data available") raise ValueError("No data available")

View File

@@ -22,21 +22,22 @@ logger = get_logger(__name__)
class ElecPriceImportCommonSettings(SettingsBaseModel): class ElecPriceImportCommonSettings(SettingsBaseModel):
"""Common settings for elecprice data import from file or JSON String.""" """Common settings for elecprice data import from file or JSON String."""
elecpriceimport_file_path: Optional[Union[str, Path]] = Field( import_file_path: Optional[Union[str, Path]] = Field(
default=None, description="Path to the file to import elecprice data from." default=None,
description="Path to the file to import elecprice data from.",
examples=[None, "/path/to/prices.json"],
) )
elecpriceimport_json: Optional[str] = Field( import_json: Optional[str] = Field(
default=None, default=None,
description="JSON string, dictionary of electricity price forecast value lists.", description="JSON string, dictionary of electricity price forecast value lists.",
examples=['{"elecprice_marketprice_wh": [0.0003384, 0.0003318, 0.0003284]}'],
) )
# Validators # Validators
@field_validator("elecpriceimport_file_path", mode="after") @field_validator("import_file_path", mode="after")
@classmethod @classmethod
def validate_elecpriceimport_file_path( def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
cls, value: Optional[Union[str, Path]]
) -> Optional[Path]:
if value is None: if value is None:
return None return None
if isinstance(value, str): if isinstance(value, str):
@@ -62,7 +63,12 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
return "ElecPriceImport" return "ElecPriceImport"
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.elecpriceimport_file_path is not None: if self.config.elecprice.provider_settings.import_file_path:
self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice") self.import_from_file(
if self.config.elecpriceimport_json is not None: self.config.elecprice.provider_settings.import_file_path,
self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice") key_prefix="elecprice",
)
if self.config.elecprice.provider_settings.import_json:
self.import_from_json(
self.config.elecprice.provider_settings.import_json, key_prefix="elecprice"
)

View File

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

View File

@@ -1,18 +1,26 @@
"""Load forecast module for load predictions.""" """Load forecast module for load predictions."""
from typing import Optional from typing import Optional, Union
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
logger = get_logger(__name__) logger = get_logger(__name__)
class LoadCommonSettings(SettingsBaseModel): class LoadCommonSettings(SettingsBaseModel):
"""Common settings for loaod forecast providers.""" """Load Prediction Configuration."""
load_provider: Optional[str] = Field( provider: Optional[str] = Field(
default=None, description="Load provider id of provider to be used." default=None,
description="Load provider id of provider to be used.",
examples=["LoadAkkudoktor"],
)
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
Field(default=None, description="Provider settings", examples=[None])
) )

View File

@@ -33,18 +33,18 @@ class LoadProvider(PredictionProvider):
LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created. LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
Configuration variables: Configuration variables:
load_provider (str): Prediction provider for load. provider (str): Prediction provider for load.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range, end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
# overload # overload
@@ -58,4 +58,4 @@ class LoadProvider(PredictionProvider):
return "LoadProvider" return "LoadProvider"
def enabled(self) -> bool: def enabled(self) -> bool:
return self.provider_id() == self.config.load_provider return self.provider_id() == self.config.load.provider

View File

@@ -17,7 +17,7 @@ class LoadAkkudoktorCommonSettings(SettingsBaseModel):
"""Common settings for load data import from file.""" """Common settings for load data import from file."""
loadakkudoktor_year_energy: Optional[float] = Field( loadakkudoktor_year_energy: Optional[float] = Field(
default=None, description="Yearly energy consumption (kWh)." default=None, description="Yearly energy consumption (kWh).", examples=[40421]
) )
@@ -91,7 +91,9 @@ class LoadAkkudoktor(LoadProvider):
list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"])) list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"]))
) )
# Calculate values in W by relative profile data and yearly consumption given in kWh # Calculate values in W by relative profile data and yearly consumption given in kWh
data_year_energy = profile_data * self.config.loadakkudoktor_year_energy * 1000 data_year_energy = (
profile_data * self.config.load.provider_settings.loadakkudoktor_year_energy * 1000
)
except FileNotFoundError: except FileNotFoundError:
error_msg = f"Error: File {load_file} not found." error_msg = f"Error: File {load_file} not found."
logger.error(error_msg) logger.error(error_msg)
@@ -109,7 +111,7 @@ class LoadAkkudoktor(LoadProvider):
# We provide prediction starting at start of day, to be compatible to old system. # We provide prediction starting at start of day, to be compatible to old system.
# End date for prediction is prediction hours from now. # End date for prediction is prediction hours from now.
date = self.start_datetime.start_of("day") date = self.start_datetime.start_of("day")
end_date = self.start_datetime.add(hours=self.config.prediction_hours) end_date = self.start_datetime.add(hours=self.config.prediction.hours)
while compare_datetimes(date, end_date).lt: while compare_datetimes(date, end_date).lt:
# Extract mean (index 0) and standard deviation (index 1) for the given day and hour # Extract mean (index 0) and standard deviation (index 1) for the given day and hour
# Day indexing starts at 0, -1 because of that # Day indexing starts at 0, -1 because of that
@@ -127,4 +129,4 @@ class LoadAkkudoktor(LoadProvider):
self.update_value(date, values) self.update_value(date, values)
date += to_duration("1 hour") date += to_duration("1 hour")
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)

View File

@@ -22,15 +22,19 @@ logger = get_logger(__name__)
class LoadImportCommonSettings(SettingsBaseModel): class LoadImportCommonSettings(SettingsBaseModel):
"""Common settings for load data import from file or JSON string.""" """Common settings for load data import from file or JSON string."""
load_import_file_path: Optional[Union[str, Path]] = Field( import_file_path: Optional[Union[str, Path]] = Field(
default=None, description="Path to the file to import load data from." default=None,
description="Path to the file to import load data from.",
examples=[None, "/path/to/yearly_load.json"],
) )
load_import_json: Optional[str] = Field( import_json: Optional[str] = Field(
default=None, description="JSON string, dictionary of load forecast value lists." default=None,
description="JSON string, dictionary of load forecast value lists.",
examples=['{"load0_mean": [676.71, 876.19, 527.13]}'],
) )
# Validators # Validators
@field_validator("load_import_file_path", mode="after") @field_validator("import_file_path", mode="after")
@classmethod @classmethod
def validate_loadimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]: def validate_loadimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
if value is None: if value is None:
@@ -58,7 +62,7 @@ class LoadImport(LoadProvider, PredictionImportProvider):
return "LoadImport" return "LoadImport"
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.load_import_file_path is not None: if self.config.load.provider_settings.import_file_path:
self.import_from_file(self.config.load_import_file_path, key_prefix="load") self.import_from_file(self.config.provider_settings.import_file_path, key_prefix="load")
if self.config.load_import_json is not None: if self.config.load.provider_settings.import_json:
self.import_from_json(self.config.load_import_json, key_prefix="load") self.import_from_json(self.config.load.provider_settings.import_json, key_prefix="load")

View File

@@ -28,7 +28,7 @@ Attributes:
from typing import List, Optional, Union from typing import List, Optional, Union
from pydantic import Field, computed_field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
@@ -41,65 +41,34 @@ from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport from akkudoktoreos.prediction.weatherimport import WeatherImport
from akkudoktoreos.utils.datetimeutil import to_timezone
class PredictionCommonSettings(SettingsBaseModel): class PredictionCommonSettings(SettingsBaseModel):
"""Base configuration for prediction settings, including forecast duration, geographic location, and time zone. """General Prediction Configuration.
This class provides configuration for prediction settings, allowing users to specify This class provides configuration for prediction settings, allowing users to specify
parameters such as the forecast duration (in hours) and location (latitude and longitude). parameters such as the forecast duration (in hours).
Validators ensure each parameter is within a specified range. A computed property, `timezone`, Validators ensure each parameter is within a specified range.
determines the time zone based on latitude and longitude.
Attributes: Attributes:
prediction_hours (Optional[int]): Number of hours into the future for predictions. hours (Optional[int]): Number of hours into the future for predictions.
Must be non-negative. Must be non-negative.
prediction_historic_hours (Optional[int]): Number of hours into the past for historical data. historic_hours (Optional[int]): Number of hours into the past for historical data.
Must be non-negative. Must be non-negative.
latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.
longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.
Properties:
timezone (Optional[str]): Computed time zone string based on the specified latitude
and longitude.
Validators: Validators:
validate_prediction_hours (int): Ensures `prediction_hours` is a non-negative integer. validate_hours (int): Ensures `hours` is a non-negative integer.
validate_prediction_historic_hours (int): Ensures `prediction_historic_hours` is a non-negative integer. validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.
validate_latitude (float): Ensures `latitude` is within the range -90 to 90.
validate_longitude (float): Ensures `longitude` is within the range -180 to 180.
""" """
prediction_hours: Optional[int] = Field( hours: Optional[int] = Field(
default=48, ge=0, description="Number of hours into the future for predictions" default=48, ge=0, description="Number of hours into the future for predictions"
) )
prediction_historic_hours: Optional[int] = Field( historic_hours: Optional[int] = Field(
default=48, default=48,
ge=0, ge=0,
description="Number of hours into the past for historical predictions data", description="Number of hours into the past for historical predictions data",
) )
latitude: Optional[float] = Field(
default=None,
ge=-90.0,
le=90.0,
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
)
longitude: Optional[float] = Field(
default=None,
ge=-180.0,
le=180.0,
description="Longitude in decimal degrees, within -180 to 180 (°)",
)
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def timezone(self) -> Optional[str]:
"""Compute timezone based on latitude and longitude."""
if self.latitude and self.longitude:
return to_timezone(location=(self.latitude, self.longitude), as_string=True)
return None
class Prediction(PredictionContainer): class Prediction(PredictionContainer):

View File

@@ -114,16 +114,16 @@ class PredictionStartEndKeepMixin(PredictionBase):
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def end_datetime(self) -> Optional[DateTime]: def end_datetime(self) -> Optional[DateTime]:
"""Compute the end datetime based on the `start_datetime` and `prediction_hours`. """Compute the end datetime based on the `start_datetime` and `hours`.
Ajusts the calculated end time if DST transitions occur within the prediction window. Ajusts the calculated end time if DST transitions occur within the prediction window.
Returns: Returns:
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing. Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
""" """
if self.start_datetime and self.config.prediction_hours: if self.start_datetime and self.config.prediction.hours:
end_datetime = self.start_datetime + to_duration( end_datetime = self.start_datetime + to_duration(
f"{self.config.prediction_hours} hours" f"{self.config.prediction.hours} hours"
) )
dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours
logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}") logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}")
@@ -147,10 +147,10 @@ class PredictionStartEndKeepMixin(PredictionBase):
return None return None
historic_hours = self.historic_hours_min() historic_hours = self.historic_hours_min()
if ( if (
self.config.prediction_historic_hours self.config.prediction.historic_hours
and self.config.prediction_historic_hours > historic_hours and self.config.prediction.historic_hours > historic_hours
): ):
historic_hours = int(self.config.prediction_historic_hours) historic_hours = int(self.config.prediction.historic_hours)
return self.start_datetime - to_duration(f"{historic_hours} hours") return self.start_datetime - to_duration(f"{historic_hours} hours")
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]

View File

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

View File

@@ -28,18 +28,18 @@ class PVForecastProvider(PredictionProvider):
PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created. PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
Configuration variables: Configuration variables:
pvforecast_provider (str): Prediction provider for pvforecast. provider (str): Prediction provider for pvforecast.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions (inlcusive), defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions (inlcusive), defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range (exclusive), end_datetime (datetime, computed): The datetime representing the end of the prediction range (exclusive),
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data (inclusive), calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data (inclusive), calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
# overload # overload
@@ -54,6 +54,6 @@ class PVForecastProvider(PredictionProvider):
def enabled(self) -> bool: def enabled(self) -> bool:
logger.debug( logger.debug(
f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast_provider}" f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.provider}"
) )
return self.provider_id() == self.config.pvforecast_provider return self.provider_id() == self.config.pvforecast.provider

View File

@@ -14,21 +14,33 @@ Classes:
Example: Example:
# Set up the configuration with necessary fields for URL generation # Set up the configuration with necessary fields for URL generation
settings_data = { settings_data = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24,
"latitude": 52.52, "latitude": 52.52,
"longitude": 13.405, "longitude": 13.405,
"pvforecast_provider": "Akkudoktor", },
"pvforecast0_peakpower": 5.0, "prediction": {
"pvforecast0_surface_azimuth": -10, "hours": 48,
"pvforecast0_surface_tilt": 7, "historic_hours": 24,
"pvforecast0_userhorizon": [20, 27, 22, 20], },
"pvforecast0_inverter_paco": 10000, "pvforecast": {
"pvforecast1_peakpower": 4.8, "provider": "PVForecastAkkudoktor",
"pvforecast1_surface_azimuth": -90, "planes": [
"pvforecast1_surface_tilt": 7, {
"pvforecast1_userhorizon": [30, 30, 30, 50], "peakpower": 5.0,
"pvforecast1_inverter_paco": 10000, "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,
}
]
}
} }
# Create the config instance from the provided data # Create the config instance from the provided data
@@ -47,12 +59,12 @@ Example:
print(forecast.report_ac_power_and_measurement()) print(forecast.report_ac_power_and_measurement())
Attributes: Attributes:
prediction_hours (int): Number of hours into the future to forecast. Default is 48. hours (int): Number of hours into the future to forecast. Default is 48.
prediction_historic_hours (int): Number of past hours to retain for analysis. Default is 24. historic_hours (int): Number of past hours to retain for analysis. Default is 24.
latitude (float): Latitude for the forecast location. latitude (float): Latitude for the forecast location.
longitude (float): Longitude for the forecast location. longitude (float): Longitude for the forecast location.
start_datetime (datetime): Start time for the forecast, defaulting to current datetime. start_datetime (datetime): Start time for the forecast, defaulting to current datetime.
end_datetime (datetime): Computed end datetime based on `start_datetime` and `prediction_hours`. end_datetime (datetime): Computed end datetime based on `start_datetime` and `hours`.
keep_datetime (datetime): Computed threshold datetime for retaining historical data. keep_datetime (datetime): Computed threshold datetime for retaining historical data.
Methods: Methods:
@@ -159,13 +171,13 @@ class PVForecastAkkudoktor(PVForecastProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
prediction_hours (int, optional): Number of hours in the future for the forecast. hours (int, optional): Number of hours in the future for the forecast.
prediction_historic_hours (int, optional): Number of past hours for retaining data. historic_hours (int, optional): Number of past hours for retaining data.
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90. latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180. longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime. start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -203,19 +215,19 @@ class PVForecastAkkudoktor(PVForecastProvider):
"""Build akkudoktor.net API request URL.""" """Build akkudoktor.net API request URL."""
base_url = "https://api.akkudoktor.net/forecast" base_url = "https://api.akkudoktor.net/forecast"
query_params = [ query_params = [
f"lat={self.config.latitude}", f"lat={self.config.general.latitude}",
f"lon={self.config.longitude}", f"lon={self.config.general.longitude}",
] ]
for i in range(len(self.config.pvforecast_planes)): for i in range(len(self.config.pvforecast.planes)):
query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}") query_params.append(f"power={int(self.config.pvforecast.planes_peakpower[i] * 1000)}")
query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}") query_params.append(f"azimuth={int(self.config.pvforecast.planes_azimuth[i])}")
query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}") query_params.append(f"tilt={int(self.config.pvforecast.planes_tilt[i])}")
query_params.append( query_params.append(
f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}" f"powerInverter={int(self.config.pvforecast.planes_inverter_paco[i])}"
) )
horizon_values = ",".join( horizon_values = ",".join(
str(int(h)) for h in self.config.pvforecast_planes_userhorizon[i] str(int(h)) for h in self.config.pvforecast.planes_userhorizon[i]
) )
query_params.append(f"horizont={horizon_values}") query_params.append(f"horizont={horizon_values}")
@@ -226,7 +238,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
"cellCoEff=-0.36", "cellCoEff=-0.36",
"inverterEfficiency=0.8", "inverterEfficiency=0.8",
"albedo=0.25", "albedo=0.25",
f"timezone={self.config.timezone}", f"timezone={self.config.general.timezone}",
"hourly=relativehumidity_2m%2Cwindspeed_10m", "hourly=relativehumidity_2m%2Cwindspeed_10m",
] ]
) )
@@ -255,7 +267,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
logger.debug(f"Response from {self._url()}: {response}") logger.debug(f"Response from {self._url()}: {response}")
akkudoktor_data = self._validate_data(response.content) akkudoktor_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return akkudoktor_data return akkudoktor_data
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
@@ -265,7 +277,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
`PVForecastAkkudoktorDataRecord`. `PVForecastAkkudoktorDataRecord`.
""" """
# Assure we have something to request PV power for. # Assure we have something to request PV power for.
if not self.config.pvforecast_planes: if not self.config.pvforecast.planes:
# No planes for PV # No planes for PV
error_msg = "Requested PV forecast, but no planes configured." error_msg = "Requested PV forecast, but no planes configured."
logger.error(f"Configuration error: {error_msg}") logger.error(f"Configuration error: {error_msg}")
@@ -275,17 +287,17 @@ class PVForecastAkkudoktor(PVForecastProvider):
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
# Timezone of the PV system # Timezone of the PV system
if self.config.timezone != akkudoktor_data.meta.timezone: if self.config.general.timezone != akkudoktor_data.meta.timezone:
error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'." error_msg = f"Configured timezone '{self.config.general.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
logger.error(f"Akkudoktor schema change: {error_msg}") logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg) raise ValueError(error_msg)
# Assumption that all lists are the same length and are ordered chronologically # Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps. # in ascending order and have the same timestamps.
if len(akkudoktor_data.values[0]) < self.config.prediction_hours: if len(akkudoktor_data.values[0]) < self.config.prediction.hours:
# Expect one value set per prediction hour # Expect one value set per prediction hour
error_msg = ( error_msg = (
f"The forecast must cover at least {self.config.prediction_hours} hours, " f"The forecast must cover at least {self.config.prediction.hours} hours, "
f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data." f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data."
) )
logger.error(f"Akkudoktor schema change: {error_msg}") logger.error(f"Akkudoktor schema change: {error_msg}")
@@ -296,7 +308,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
# Iterate over forecast data points # Iterate over forecast data points
for forecast_values in zip(*akkudoktor_data.values): for forecast_values in zip(*akkudoktor_data.values):
original_datetime = forecast_values[0].datetime original_datetime = forecast_values[0].datetime
dt = to_datetime(original_datetime, in_timezone=self.config.timezone) dt = to_datetime(original_datetime, in_timezone=self.config.general.timezone)
# Skip outdated forecast data # Skip outdated forecast data
if compare_datetimes(dt, self.start_datetime.start_of("day")).lt: if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
@@ -314,9 +326,9 @@ class PVForecastAkkudoktor(PVForecastProvider):
self.update_value(dt, data) self.update_value(dt, data)
if len(self) < self.config.prediction_hours: if len(self) < self.config.prediction.hours:
raise ValueError( raise ValueError(
f"The forecast must cover at least {self.config.prediction_hours} hours, " f"The forecast must cover at least {self.config.prediction.hours} hours, "
f"but only {len(self)} hours starting from {self.start_datetime} " f"but only {len(self)} hours starting from {self.start_datetime} "
f"were predicted." f"were predicted."
) )
@@ -365,31 +377,47 @@ if __name__ == "__main__":
""" """
# Set up the configuration with necessary fields for URL generation # Set up the configuration with necessary fields for URL generation
settings_data = { settings_data = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24,
"latitude": 52.52, "latitude": 52.52,
"longitude": 13.405, "longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor", },
"pvforecast0_peakpower": 5.0, "prediction": {
"pvforecast0_surface_azimuth": -10, "hours": 48,
"pvforecast0_surface_tilt": 7, "historic_hours": 24,
"pvforecast0_userhorizon": [20, 27, 22, 20], },
"pvforecast0_inverter_paco": 10000, "pvforecast": {
"pvforecast1_peakpower": 4.8, "provider": "PVForecastAkkudoktor",
"pvforecast1_surface_azimuth": -90, "planes": [
"pvforecast1_surface_tilt": 7, {
"pvforecast1_userhorizon": [30, 30, 30, 50], "peakpower": 5.0,
"pvforecast1_inverter_paco": 10000, "surface_azimuth": -10,
"pvforecast2_peakpower": 1.4, "surface_tilt": 7,
"pvforecast2_surface_azimuth": -40, "userhorizon": [20, 27, 22, 20],
"pvforecast2_surface_tilt": 60, "inverter_paco": 10000,
"pvforecast2_userhorizon": [60, 30, 0, 30], },
"pvforecast2_inverter_paco": 2000, {
"pvforecast3_peakpower": 1.6, "peakpower": 4.8,
"pvforecast3_surface_azimuth": 5, "surface_azimuth": -90,
"pvforecast3_surface_tilt": 45, "surface_tilt": 7,
"pvforecast3_userhorizon": [45, 25, 30, 60], "userhorizon": [30, 30, 30, 50],
"pvforecast3_inverter_paco": 1400, "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,
},
],
},
} }
# Initialize the forecast object with the generated configuration # Initialize the forecast object with the generated configuration

View File

@@ -22,21 +22,22 @@ logger = get_logger(__name__)
class PVForecastImportCommonSettings(SettingsBaseModel): class PVForecastImportCommonSettings(SettingsBaseModel):
"""Common settings for pvforecast data import from file or JSON string.""" """Common settings for pvforecast data import from file or JSON string."""
pvforecastimport_file_path: Optional[Union[str, Path]] = Field( import_file_path: Optional[Union[str, Path]] = Field(
default=None, description="Path to the file to import PV forecast data from." default=None,
description="Path to the file to import PV forecast data from.",
examples=[None, "/path/to/pvforecast.json"],
) )
pvforecastimport_json: Optional[str] = Field( import_json: Optional[str] = Field(
default=None, default=None,
description="JSON string, dictionary of PV forecast value lists.", description="JSON string, dictionary of PV forecast value lists.",
examples=['{"pvforecast_ac_power": [0, 8.05, 352.91]}'],
) )
# Validators # Validators
@field_validator("pvforecastimport_file_path", mode="after") @field_validator("import_file_path", mode="after")
@classmethod @classmethod
def validate_pvforecastimport_file_path( def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
cls, value: Optional[Union[str, Path]]
) -> Optional[Path]:
if value is None: if value is None:
return None return None
if isinstance(value, str): if isinstance(value, str):
@@ -62,7 +63,13 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
return "PVForecastImport" return "PVForecastImport"
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.pvforecastimport_file_path is not None: if self.config.pvforecast.provider_settings.import_file_path is not None:
self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast") self.import_from_file(
if self.config.pvforecastimport_json is not None: self.config.pvforecast.provider_settings.import_file_path,
self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast") key_prefix="pvforecast",
)
if self.config.pvforecast.provider_settings.import_json is not None:
self.import_from_json(
self.config.pvforecast.provider_settings.import_json,
key_prefix="pvforecast",
)

View File

@@ -5,9 +5,18 @@ from typing import Optional
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
class WeatherCommonSettings(SettingsBaseModel): class WeatherCommonSettings(SettingsBaseModel):
weather_provider: Optional[str] = Field( """Weather Forecast Configuration."""
default=None, description="Weather provider id of provider to be used."
provider: Optional[str] = Field(
default=None,
description="Weather provider id of provider to be used.",
examples=["WeatherImport"],
)
provider_settings: Optional[WeatherImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
) )

View File

@@ -101,18 +101,18 @@ class WeatherProvider(PredictionProvider):
WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created. WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
Configuration variables: Configuration variables:
weather_provider (str): Prediction provider for weather. provider (str): Prediction provider for weather.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range, end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
# overload # overload
@@ -126,7 +126,7 @@ class WeatherProvider(PredictionProvider):
return "WeatherProvider" return "WeatherProvider"
def enabled(self) -> bool: def enabled(self) -> bool:
return self.provider_id() == self.config.weather_provider return self.provider_id() == self.config.weather.provider
@classmethod @classmethod
def estimate_irradiance_from_cloud_cover( def estimate_irradiance_from_cloud_cover(

View File

@@ -62,13 +62,13 @@ class WeatherBrightSky(WeatherProvider):
of hours into the future and retains historical data. of hours into the future and retains historical data.
Attributes: Attributes:
prediction_hours (int, optional): Number of hours in the future for the forecast. hours (int, optional): Number of hours in the future for the forecast.
prediction_historic_hours (int, optional): Number of past hours for retaining data. historic_hours (int, optional): Number of past hours for retaining data.
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90. latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180. longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime. start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`. end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_hours`. keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
Methods: Methods:
provider_id(): Returns a unique identifier for the provider. provider_id(): Returns a unique identifier for the provider.
@@ -99,7 +99,7 @@ class WeatherBrightSky(WeatherProvider):
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD") date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD")
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
response = requests.get( response = requests.get(
f"{source}/weather?lat={self.config.latitude}&lon={self.config.longitude}&date={date}&last_date={last_date}&tz={self.config.timezone}" f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}"
) )
response.raise_for_status() # Raise an error for bad responses response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}") logger.debug(f"Response from {source}: {response}")
@@ -109,7 +109,7 @@ class WeatherBrightSky(WeatherProvider):
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return brightsky_data return brightsky_data
def _description_to_series(self, description: str) -> pd.Series: def _description_to_series(self, description: str) -> pd.Series:
@@ -200,7 +200,7 @@ class WeatherBrightSky(WeatherProvider):
description = "Total Clouds (% Sky Obscured)" description = "Total Clouds (% Sky Obscured)"
cloud_cover = self._description_to_series(description) cloud_cover = self._description_to_series(description)
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover( ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
self.config.latitude, self.config.longitude, cloud_cover self.config.general.latitude, self.config.general.longitude, cloud_cover
) )
description = "Global Horizontal Irradiance (W/m2)" description = "Global Horizontal Irradiance (W/m2)"

View File

@@ -68,15 +68,15 @@ class WeatherClearOutside(WeatherProvider):
WeatherClearOutside is a thread-safe singleton, ensuring only one instance of this class is created. WeatherClearOutside is a thread-safe singleton, ensuring only one instance of this class is created.
Attributes: Attributes:
prediction_hours (int, optional): The number of hours into the future for which predictions are generated. hours (int, optional): The number of hours into the future for which predictions are generated.
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained. historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90. latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180. longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified. start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range, end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `prediction_hours`. calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `prediction_historic_hours`. based on `start_datetime` and `historic_hours`.
""" """
@classmethod @classmethod
@@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider):
response: Weather forecast request reponse from ClearOutside. response: Weather forecast request reponse from ClearOutside.
""" """
source = "https://clearoutside.com/forecast" source = "https://clearoutside.com/forecast"
latitude = round(self.config.latitude, 2) latitude = round(self.config.general.latitude, 2)
longitude = round(self.config.longitude, 2) longitude = round(self.config.general.longitude, 2)
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true") response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
response.raise_for_status() # Raise an error for bad responses response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}") logger.debug(f"Response from {source}: {response}")
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return response return response
def _update_data(self, force_update: Optional[bool] = None) -> None: def _update_data(self, force_update: Optional[bool] = None) -> None:
@@ -307,7 +307,7 @@ class WeatherClearOutside(WeatherProvider):
data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"] data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"]
) )
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover( ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
self.config.latitude, self.config.longitude, cloud_cover self.config.general.latitude, self.config.general.longitude, cloud_cover
) )
# Add GHI, DNI, DHI to clearout data # Add GHI, DNI, DHI to clearout data

View File

@@ -22,18 +22,22 @@ logger = get_logger(__name__)
class WeatherImportCommonSettings(SettingsBaseModel): class WeatherImportCommonSettings(SettingsBaseModel):
"""Common settings for weather data import from file or JSON string.""" """Common settings for weather data import from file or JSON string."""
weatherimport_file_path: Optional[Union[str, Path]] = Field( import_file_path: Optional[Union[str, Path]] = Field(
default=None, description="Path to the file to import weather data from." default=None,
description="Path to the file to import weather data from.",
examples=[None, "/path/to/weather_data.json"],
) )
weatherimport_json: Optional[str] = Field( import_json: Optional[str] = Field(
default=None, description="JSON string, dictionary of weather forecast value lists." default=None,
description="JSON string, dictionary of weather forecast value lists.",
examples=['{"weather_temp_air": [18.3, 17.8, 16.9]}'],
) )
# Validators # Validators
@field_validator("weatherimport_file_path", mode="after") @field_validator("import_file_path", mode="after")
@classmethod @classmethod
def validate_weatherimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]: def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
if value is None: if value is None:
return None return None
if isinstance(value, str): if isinstance(value, str):
@@ -59,7 +63,11 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
return "WeatherImport" return "WeatherImport"
def _update_data(self, force_update: Optional[bool] = False) -> None: def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.weatherimport_file_path is not None: if self.config.weather.provider_settings.import_file_path:
self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather") self.import_from_file(
if self.config.weatherimport_json is not None: self.config.weather.provider_settings.import_file_path, key_prefix="weather"
self.import_from_json(self.config.weatherimport_json, key_prefix="weather") )
if self.config.weather.provider_settings.import_json:
self.import_from_json(
self.config.weather.provider_settings.import_json, key_prefix="weather"
)

View File

@@ -29,7 +29,11 @@ from akkudoktoreos.optimization.genetic import (
OptimizeResponse, OptimizeResponse,
optimization_problem, optimization_problem,
) )
from akkudoktoreos.prediction.prediction import get_prediction from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -149,16 +153,16 @@ def start_eosdash() -> subprocess.Popen:
if args is None: if args is None:
# No command line arguments # No command line arguments
host = config_eos.server_eosdash_host host = config_eos.server.eosdash_host
port = config_eos.server_eosdash_port port = config_eos.server.eosdash_port
eos_host = config_eos.server_eos_host eos_host = config_eos.server.host
eos_port = config_eos.server_eos_port eos_port = config_eos.server.port
log_level = "info" log_level = "info"
access_log = False access_log = False
reload = False reload = False
else: else:
host = args.host host = args.host
port = config_eos.server_eosdash_port if config_eos.server_eosdash_port else (args.port + 1) port = config_eos.server.eosdash_port if config_eos.server.eosdash_port else (args.port + 1)
eos_host = args.host eos_host = args.host
eos_port = args.port eos_port = args.port
log_level = args.log_level log_level = args.log_level
@@ -201,7 +205,7 @@ def start_eosdash() -> subprocess.Popen:
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Lifespan manager for the app.""" """Lifespan manager for the app."""
# On startup # On startup
if config_eos.server_eos_startup_eosdash: if config_eos.server.startup_eosdash:
try: try:
eosdash_process = start_eosdash() eosdash_process = start_eosdash()
except Exception as e: except Exception as e:
@@ -226,10 +230,6 @@ app = FastAPI(
root_path=str(Path(__file__).parent), root_path=str(Path(__file__).parent),
) )
# That's the problem
opt_class = optimization_problem(verbose=bool(config_eos.server_eos_verbose))
server_dir = Path(__file__).parent.resolve() server_dir = Path(__file__).parent.resolve()
@@ -237,66 +237,24 @@ class PdfResponse(FileResponse):
media_type = "application/pdf" media_type = "application/pdf"
@app.put("/v1/config/value") @app.put("/v1/config/reset", tags=["config"])
def fastapi_config_value_put(
key: Annotated[str, Query(description="configuration key")],
value: Annotated[Any, Query(description="configuration value")],
) -> ConfigEOS:
"""Set the configuration option in the settings.
Args:
key (str): configuration key
value (Any): configuration value
Returns:
configuration (ConfigEOS): The current configuration after the write.
"""
if key not in config_eos.config_keys:
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
if key in config_eos.config_keys_read_only:
raise HTTPException(status_code=404, detail=f"Key '{key}' is read only.")
try:
setattr(config_eos, key, value)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}")
return config_eos
@app.post("/v1/config/update")
def fastapi_config_update_post() -> ConfigEOS: def fastapi_config_update_post() -> ConfigEOS:
"""Update the configuration from the EOS configuration file. """Reset the configuration to the EOS configuration file.
Returns: Returns:
configuration (ConfigEOS): The current configuration after update. configuration (ConfigEOS): The current configuration after update.
""" """
try: try:
_, config_file_path = config_eos.from_config_file() config_eos.reset_settings()
except: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"Cannot update configuration from file '{config_file_path}'.", detail=f"Cannot update configuration from file '{config_eos.config_file_path}': {e}",
) )
return config_eos return config_eos
@app.get("/v1/config/file") @app.put("/v1/config/file", tags=["config"])
def fastapi_config_file_get() -> SettingsEOS:
"""Get the settings as defined by the EOS configuration file.
Returns:
settings (SettingsEOS): The settings defined by the EOS configuration file.
"""
try:
settings, config_file_path = config_eos.settings_from_config_file()
except:
raise HTTPException(
status_code=404,
detail=f"Cannot read configuration from file '{config_file_path}'.",
)
return settings
@app.put("/v1/config/file")
def fastapi_config_file_put() -> ConfigEOS: def fastapi_config_file_put() -> ConfigEOS:
"""Save the current configuration to the EOS configuration file. """Save the current configuration to the EOS configuration file.
@@ -313,7 +271,7 @@ def fastapi_config_file_put() -> ConfigEOS:
return config_eos return config_eos
@app.get("/v1/config") @app.get("/v1/config", tags=["config"])
def fastapi_config_get() -> ConfigEOS: def fastapi_config_get() -> ConfigEOS:
"""Get the current configuration. """Get the current configuration.
@@ -323,15 +281,13 @@ def fastapi_config_get() -> ConfigEOS:
return config_eos return config_eos
@app.put("/v1/config") @app.put("/v1/config", tags=["config"])
def fastapi_config_put( def fastapi_config_put(settings: SettingsEOS) -> ConfigEOS:
settings: Annotated[SettingsEOS, Query(description="settings")], """Update the current config with the provided settings.
) -> ConfigEOS:
"""Write the provided settings into the current settings.
The existing settings are completely overwritten. Note that for any setting Note that for any setting value that is None or unset, the configuration will fall back to
value that is None, the configuration will fall back to values from other sources such as values from other sources such as environment variables, the EOS configuration file, or default
environment variables, the EOS configuration file, or default values. values.
Args: Args:
settings (SettingsEOS): The settings to write into the current settings. settings (SettingsEOS): The settings to write into the current settings.
@@ -340,19 +296,19 @@ def fastapi_config_put(
configuration (ConfigEOS): The current configuration after the write. configuration (ConfigEOS): The current configuration after the write.
""" """
try: try:
config_eos.merge_settings(settings, force=True) config_eos.merge_settings(settings)
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}") raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}")
return config_eos return config_eos
@app.get("/v1/measurement/keys") @app.get("/v1/measurement/keys", tags=["measurement"])
def fastapi_measurement_keys_get() -> list[str]: def fastapi_measurement_keys_get() -> list[str]:
"""Get a list of available measurement keys.""" """Get a list of available measurement keys."""
return sorted(measurement_eos.record_keys) return sorted(measurement_eos.record_keys)
@app.get("/v1/measurement/load-mr/series/by-name") @app.get("/v1/measurement/load-mr/series/by-name", tags=["measurement"])
def fastapi_measurement_load_mr_series_by_name_get( def fastapi_measurement_load_mr_series_by_name_get(
name: Annotated[str, Query(description="Load name.")], name: Annotated[str, Query(description="Load name.")],
) -> PydanticDateTimeSeries: ) -> PydanticDateTimeSeries:
@@ -368,7 +324,7 @@ def fastapi_measurement_load_mr_series_by_name_get(
return PydanticDateTimeSeries.from_series(pdseries) return PydanticDateTimeSeries.from_series(pdseries)
@app.put("/v1/measurement/load-mr/value/by-name") @app.put("/v1/measurement/load-mr/value/by-name", tags=["measurement"])
def fastapi_measurement_load_mr_value_by_name_put( def fastapi_measurement_load_mr_value_by_name_put(
datetime: Annotated[str, Query(description="Datetime.")], datetime: Annotated[str, Query(description="Datetime.")],
name: Annotated[str, Query(description="Load name.")], name: Annotated[str, Query(description="Load name.")],
@@ -387,7 +343,7 @@ def fastapi_measurement_load_mr_value_by_name_put(
return PydanticDateTimeSeries.from_series(pdseries) return PydanticDateTimeSeries.from_series(pdseries)
@app.put("/v1/measurement/load-mr/series/by-name") @app.put("/v1/measurement/load-mr/series/by-name", tags=["measurement"])
def fastapi_measurement_load_mr_series_by_name_put( def fastapi_measurement_load_mr_series_by_name_put(
name: Annotated[str, Query(description="Load name.")], series: PydanticDateTimeSeries name: Annotated[str, Query(description="Load name.")], series: PydanticDateTimeSeries
) -> PydanticDateTimeSeries: ) -> PydanticDateTimeSeries:
@@ -405,7 +361,7 @@ def fastapi_measurement_load_mr_series_by_name_put(
return PydanticDateTimeSeries.from_series(pdseries) return PydanticDateTimeSeries.from_series(pdseries)
@app.get("/v1/measurement/series") @app.get("/v1/measurement/series", tags=["measurement"])
def fastapi_measurement_series_get( def fastapi_measurement_series_get(
key: Annotated[str, Query(description="Prediction key.")], key: Annotated[str, Query(description="Prediction key.")],
) -> PydanticDateTimeSeries: ) -> PydanticDateTimeSeries:
@@ -416,7 +372,7 @@ def fastapi_measurement_series_get(
return PydanticDateTimeSeries.from_series(pdseries) return PydanticDateTimeSeries.from_series(pdseries)
@app.put("/v1/measurement/value") @app.put("/v1/measurement/value", tags=["measurement"])
def fastapi_measurement_value_put( def fastapi_measurement_value_put(
datetime: Annotated[str, Query(description="Datetime.")], datetime: Annotated[str, Query(description="Datetime.")],
key: Annotated[str, Query(description="Prediction key.")], key: Annotated[str, Query(description="Prediction key.")],
@@ -430,7 +386,7 @@ def fastapi_measurement_value_put(
return PydanticDateTimeSeries.from_series(pdseries) return PydanticDateTimeSeries.from_series(pdseries)
@app.put("/v1/measurement/series") @app.put("/v1/measurement/series", tags=["measurement"])
def fastapi_measurement_series_put( def fastapi_measurement_series_put(
key: Annotated[str, Query(description="Prediction key.")], series: PydanticDateTimeSeries key: Annotated[str, Query(description="Prediction key.")], series: PydanticDateTimeSeries
) -> PydanticDateTimeSeries: ) -> PydanticDateTimeSeries:
@@ -443,27 +399,47 @@ def fastapi_measurement_series_put(
return PydanticDateTimeSeries.from_series(pdseries) return PydanticDateTimeSeries.from_series(pdseries)
@app.put("/v1/measurement/dataframe") @app.put("/v1/measurement/dataframe", tags=["measurement"])
def fastapi_measurement_dataframe_put(data: PydanticDateTimeDataFrame) -> None: def fastapi_measurement_dataframe_put(data: PydanticDateTimeDataFrame) -> None:
"""Merge the measurement data given as dataframe into EOS measurements.""" """Merge the measurement data given as dataframe into EOS measurements."""
dataframe = data.to_dataframe() dataframe = data.to_dataframe()
measurement_eos.import_from_dataframe(dataframe) measurement_eos.import_from_dataframe(dataframe)
@app.put("/v1/measurement/data") @app.put("/v1/measurement/data", tags=["measurement"])
def fastapi_measurement_data_put(data: PydanticDateTimeData) -> None: def fastapi_measurement_data_put(data: PydanticDateTimeData) -> None:
"""Merge the measurement data given as datetime data into EOS measurements.""" """Merge the measurement data given as datetime data into EOS measurements."""
datetimedata = data.to_dict() datetimedata = data.to_dict()
measurement_eos.import_from_dict(datetimedata) measurement_eos.import_from_dict(datetimedata)
@app.get("/v1/prediction/keys") @app.get("/v1/prediction/providers", tags=["prediction"])
def fastapi_prediction_providers_get(enabled: Optional[bool] = None) -> list[str]:
"""Get a list of available prediction providers.
Args:
enabled (bool): Return enabled/disabled providers. If unset, return all providers.
"""
if enabled is not None:
enabled_status = [enabled]
else:
enabled_status = [True, False]
return sorted(
[
provider.provider_id()
for provider in prediction_eos.providers
if provider.enabled() in enabled_status
]
)
@app.get("/v1/prediction/keys", tags=["prediction"])
def fastapi_prediction_keys_get() -> list[str]: def fastapi_prediction_keys_get() -> list[str]:
"""Get a list of available prediction keys.""" """Get a list of available prediction keys."""
return sorted(prediction_eos.record_keys) return sorted(prediction_eos.record_keys)
@app.get("/v1/prediction/series") @app.get("/v1/prediction/series", tags=["prediction"])
def fastapi_prediction_series_get( def fastapi_prediction_series_get(
key: Annotated[str, Query(description="Prediction key.")], key: Annotated[str, Query(description="Prediction key.")],
start_datetime: Annotated[ start_datetime: Annotated[
@@ -500,7 +476,7 @@ def fastapi_prediction_series_get(
return PydanticDateTimeSeries.from_series(pdseries) return PydanticDateTimeSeries.from_series(pdseries)
@app.get("/v1/prediction/list") @app.get("/v1/prediction/list", tags=["prediction"])
def fastapi_prediction_list_get( def fastapi_prediction_list_get(
key: Annotated[str, Query(description="Prediction key.")], key: Annotated[str, Query(description="Prediction key.")],
start_datetime: Annotated[ start_datetime: Annotated[
@@ -550,7 +526,7 @@ def fastapi_prediction_list_get(
return prediction_list return prediction_list
@app.post("/v1/prediction/update") @app.post("/v1/prediction/update", tags=["prediction"])
def fastapi_prediction_update(force_update: bool = False, force_enable: bool = False) -> Response: def fastapi_prediction_update(force_update: bool = False, force_enable: bool = False) -> Response:
"""Update predictions for all providers. """Update predictions for all providers.
@@ -563,11 +539,12 @@ def fastapi_prediction_update(force_update: bool = False, force_enable: bool = F
try: try:
prediction_eos.update_data(force_update=force_update, force_enable=force_enable) prediction_eos.update_data(force_update=force_update, force_enable=force_enable)
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f"Error on update of provider: {e}") raise e
# raise HTTPException(status_code=400, detail=f"Error on update of provider: {e}")
return Response() return Response()
@app.post("/v1/prediction/update/{provider_id}") @app.post("/v1/prediction/update/{provider_id}", tags=["prediction"])
def fastapi_prediction_update_provider( def fastapi_prediction_update_provider(
provider_id: str, force_update: Optional[bool] = False, force_enable: Optional[bool] = False provider_id: str, force_update: Optional[bool] = False, force_enable: Optional[bool] = False
) -> Response: ) -> Response:
@@ -591,7 +568,7 @@ def fastapi_prediction_update_provider(
return Response() return Response()
@app.get("/strompreis") @app.get("/strompreis", tags=["prediction"])
def fastapi_strompreis() -> list[float]: def fastapi_strompreis() -> list[float]:
"""Deprecated: Electricity Market Price Prediction per Wh (€/Wh). """Deprecated: Electricity Market Price Prediction per Wh (€/Wh).
@@ -603,14 +580,16 @@ def fastapi_strompreis() -> list[float]:
Electricity price charges are added. Electricity price charges are added.
Note: Note:
Set ElecPriceAkkudoktor as elecprice_provider, then update data with Set ElecPriceAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=elecprice_marketprice_wh' or '/v1/prediction/list?key=elecprice_marketprice_wh' or
'/v1/prediction/list?key=elecprice_marketprice_kwh' instead. '/v1/prediction/list?key=elecprice_marketprice_kwh' instead.
""" """
settings = SettingsEOS( settings = SettingsEOS(
elecprice_provider="ElecPriceAkkudoktor", elecprice=ElecPriceCommonSettings(
provider="ElecPriceAkkudoktor",
)
) )
config_eos.merge_settings(settings=settings) config_eos.merge_settings(settings=settings)
ems_eos.set_start_datetime() # Set energy management start datetime to current hour. ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
@@ -643,7 +622,7 @@ class GesamtlastRequest(PydanticBaseModel):
hours: int hours: int
@app.post("/gesamtlast") @app.post("/gesamtlast", tags=["prediction"])
def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]: def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
"""Deprecated: Total Load Prediction with adjustment. """Deprecated: Total Load Prediction with adjustment.
@@ -660,16 +639,22 @@ def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
'/v1/measurement/value' '/v1/measurement/value'
""" """
settings = SettingsEOS( settings = SettingsEOS(
prediction_hours=request.hours, prediction=PredictionCommonSettings(
load_provider="LoadAkkudoktor", hours=request.hours,
),
load=LoadCommonSettings(
provider="LoadAkkudoktor",
provider_settings=LoadAkkudoktorCommonSettings(
loadakkudoktor_year_energy=request.year_energy, loadakkudoktor_year_energy=request.year_energy,
),
),
) )
config_eos.merge_settings(settings=settings) config_eos.merge_settings(settings=settings)
ems_eos.set_start_datetime() # Set energy management start datetime to current hour. ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
# Insert measured data into EOS measurement # Insert measured data into EOS measurement
# Convert from energy per interval to dummy energy meter readings # Convert from energy per interval to dummy energy meter readings
measurement_key = "measurement_load0_mr" measurement_key = "load0_mr"
measurement_eos.key_delete_by_datetime(key=measurement_key) # delete all load0_mr measurements measurement_eos.key_delete_by_datetime(key=measurement_key) # delete all load0_mr measurements
energy = {} energy = {}
try: try:
@@ -718,7 +703,7 @@ def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
return prediction_list return prediction_list
@app.get("/gesamtlast_simple") @app.get("/gesamtlast_simple", tags=["prediction"])
def fastapi_gesamtlast_simple(year_energy: float) -> list[float]: def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
"""Deprecated: Total Load Prediction. """Deprecated: Total Load Prediction.
@@ -732,14 +717,18 @@ def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
year_energy (float): Yearly energy consumption in Wh. year_energy (float): Yearly energy consumption in Wh.
Note: Note:
Set LoadAkkudoktor as load_provider, then update data with Set LoadAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=load_mean' instead. '/v1/prediction/list?key=load_mean' instead.
""" """
settings = SettingsEOS( settings = SettingsEOS(
load_provider="LoadAkkudoktor", load=LoadCommonSettings(
provider="LoadAkkudoktor",
provider_settings=LoadAkkudoktorCommonSettings(
loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh
),
)
) )
config_eos.merge_settings(settings=settings) config_eos.merge_settings(settings=settings)
ems_eos.set_start_datetime() # Set energy management start datetime to current hour. ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
@@ -770,7 +759,7 @@ class ForecastResponse(PydanticBaseModel):
pvpower: list[float] pvpower: list[float]
@app.get("/pvforecast") @app.get("/pvforecast", tags=["prediction"])
def fastapi_pvforecast() -> ForecastResponse: def fastapi_pvforecast() -> ForecastResponse:
"""Deprecated: PV Forecast Prediction. """Deprecated: PV Forecast Prediction.
@@ -781,21 +770,25 @@ def fastapi_pvforecast() -> ForecastResponse:
filled with the first available forecast value. filled with the first available forecast value.
Note: Note:
Set PVForecastAkkudoktor as pvforecast_provider, then update data with Set PVForecastAkkudoktor as provider, then update data with
'/v1/prediction/update' '/v1/prediction/update'
and then request data with and then request data with
'/v1/prediction/list?key=pvforecast_ac_power' and '/v1/prediction/list?key=pvforecast_ac_power' and
'/v1/prediction/list?key=pvforecastakkudoktor_temp_air' instead. '/v1/prediction/list?key=pvforecastakkudoktor_temp_air' instead.
""" """
settings = SettingsEOS( settings = SettingsEOS(pvforecast=PVForecastCommonSettings(provider="PVForecastAkkudoktor"))
elecprice_provider="PVForecastAkkudoktor",
)
config_eos.merge_settings(settings=settings) config_eos.merge_settings(settings=settings)
ems_eos.set_start_datetime() # Set energy management start datetime to current hour. ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
# Create PV forecast # Create PV forecast
try:
prediction_eos.update_data(force_update=True) prediction_eos.update_data(force_update=True)
except ValueError as e:
raise HTTPException(
status_code=404,
detail=f"Can not get the PV forecast: {e}",
)
# Get the forcast starting at start of day # Get the forcast starting at start of day
start_datetime = to_datetime().start_of("day") start_datetime = to_datetime().start_of("day")
@@ -821,30 +814,35 @@ def fastapi_pvforecast() -> ForecastResponse:
return ForecastResponse(temperature=temp_air, pvpower=ac_power) return ForecastResponse(temperature=temp_air, pvpower=ac_power)
@app.post("/optimize") @app.post("/optimize", tags=["optimize"])
def fastapi_optimize( def fastapi_optimize(
parameters: OptimizationParameters, parameters: OptimizationParameters,
start_hour: Annotated[ start_hour: Annotated[
Optional[int], Query(description="Defaults to current hour of the day.") Optional[int], Query(description="Defaults to current hour of the day.")
] = None, ] = None,
ngen: Optional[int] = None,
) -> OptimizeResponse: ) -> OptimizeResponse:
if start_hour is None: if start_hour is None:
start_hour = to_datetime().hour start_hour = to_datetime().hour
extra_args: dict[str, Any] = dict()
if ngen is not None:
extra_args["ngen"] = ngen
# TODO: Remove when config and prediction update is done by EMS. # TODO: Remove when config and prediction update is done by EMS.
config_eos.update() config_eos.update()
prediction_eos.update_data() prediction_eos.update_data()
# Perform optimization simulation # Perform optimization simulation
result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour) opt_class = optimization_problem(verbose=bool(config_eos.server.verbose))
result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour, **extra_args)
# print(result) # print(result)
return result return result
@app.get("/visualization_results.pdf", response_class=PdfResponse) @app.get("/visualization_results.pdf", response_class=PdfResponse, tags=["optimize"])
def get_pdf() -> PdfResponse: def get_pdf() -> PdfResponse:
# Endpoint to serve the generated PDF with visualization results # Endpoint to serve the generated PDF with visualization results
output_path = config_eos.data_output_path output_path = config_eos.general.data_output_path
if output_path is None or not output_path.is_dir(): if output_path is None or not output_path.is_dir():
raise HTTPException(status_code=404, detail=f"Output path does not exist: {output_path}.") raise HTTPException(status_code=404, detail=f"Output path does not exist: {output_path}.")
file_path = output_path / "visualization_results.pdf" file_path = output_path / "visualization_results.pdf"
@@ -860,31 +858,34 @@ def site_map() -> RedirectResponse:
# Keep the proxy last to handle all requests that are not taken by the Rest API. # Keep the proxy last to handle all requests that are not taken by the Rest API.
if config_eos.server.startup_eosdash:
@app.delete("/{path:path}", include_in_schema=False) @app.delete("/{path:path}", include_in_schema=False)
async def proxy_delete(request: Request, path: str) -> Response: async def proxy_delete(request: Request, path: str) -> Response:
return await proxy(request, path) return await proxy(request, path)
@app.get("/{path:path}", include_in_schema=False)
@app.get("/{path:path}", include_in_schema=False) async def proxy_get(request: Request, path: str) -> Response:
async def proxy_get(request: Request, path: str) -> Response:
return await proxy(request, path) return await proxy(request, path)
@app.post("/{path:path}", include_in_schema=False)
@app.post("/{path:path}", include_in_schema=False) async def proxy_post(request: Request, path: str) -> Response:
async def proxy_post(request: Request, path: str) -> Response:
return await proxy(request, path) return await proxy(request, path)
@app.put("/{path:path}", include_in_schema=False)
@app.put("/{path:path}", include_in_schema=False) async def proxy_put(request: Request, path: str) -> Response:
async def proxy_put(request: Request, path: str) -> Response:
return await proxy(request, path) return await proxy(request, path)
else:
@app.get("/", include_in_schema=False)
def root() -> RedirectResponse:
return RedirectResponse(url="/docs")
async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse | HTMLResponse]: async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse | HTMLResponse]:
if config_eos.server_eosdash_host and config_eos.server_eosdash_port: if config_eos.server.eosdash_host and config_eos.server.eosdash_port:
# Proxy to EOSdash server # Proxy to EOSdash server
url = f"http://{config_eos.server_eosdash_host}:{config_eos.server_eosdash_port}/{path}" url = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}/{path}"
headers = dict(request.headers) headers = dict(request.headers)
data = await request.body() data = await request.body()
@@ -906,9 +907,9 @@ async def proxy(request: Request, path: str) -> Union[Response | RedirectRespons
error_message=f"""<pre> error_message=f"""<pre>
EOSdash server not reachable: '{url}' EOSdash server not reachable: '{url}'
Did you start the EOSdash server Did you start the EOSdash server
or set 'server_eos_startup_eosdash'? or set 'startup_eosdash'?
If there is no application server intended please If there is no application server intended please
set 'server_eosdash_host' or 'server_eosdash_port' to None. set 'eosdash_host' or 'eosdash_port' to None.
</pre> </pre>
""", """,
error_details=f"{e}", error_details=f"{e}",
@@ -972,8 +973,8 @@ def main() -> None:
it starts the EOS server with the specified configurations. it starts the EOS server with the specified configurations.
Command-line Arguments: Command-line Arguments:
--host (str): Host for the EOS server (default: value from config_eos). --host (str): Host for the EOS server (default: value from config).
--port (int): Port for the EOS server (default: value from config_eos). --port (int): Port for the EOS server (default: value from config).
--log_level (str): Log level for the server. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info"). --log_level (str): Log level for the server. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info").
--access_log (bool): Enable or disable access log. Options: True or False (default: False). --access_log (bool): Enable or disable access log. Options: True or False (default: False).
--reload (bool): Enable or disable auto-reload. Useful for development. Options: True or False (default: False). --reload (bool): Enable or disable auto-reload. Useful for development. Options: True or False (default: False).
@@ -984,14 +985,14 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--host", "--host",
type=str, type=str,
default=str(config_eos.server_eos_host), default=str(config_eos.server.host),
help="Host for the EOS server (default: value from config_eos)", help="Host for the EOS server (default: value from config)",
) )
parser.add_argument( parser.add_argument(
"--port", "--port",
type=int, type=int,
default=config_eos.server_eos_port, default=config_eos.server.port,
help="Port for the EOS server (default: value from config_eos)", help="Port for the EOS server (default: value from config)",
) )
# Optional arguments for log_level, access_log, and reload # Optional arguments for log_level, access_log, and reload
@@ -1019,7 +1020,7 @@ def main() -> None:
try: try:
run_eos(args.host, args.port, args.log_level, args.access_log, args.reload) run_eos(args.host, args.port, args.log_level, args.access_log, args.reload)
except: except:
exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,11 +1,17 @@
import argparse import argparse
import os import os
import sys
from functools import reduce
from typing import Any, Union
import uvicorn import uvicorn
from fasthtml.common import H1, FastHTML, Table, Td, Th, Thead, Titled, Tr from fasthtml.common import H1, Table, Td, Th, Thead, Titled, Tr, fast_app
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from akkudoktoreos.config.config import get_config from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -14,18 +20,84 @@ config_eos = get_config()
# Command line arguments # Command line arguments
args = None args = None
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 = "N/A"
return default_value
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
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
configs = [] configs = []
for field_name in config_eos.model_fields: inner_types: set[type[PydanticBaseModel]] = set()
for field_name, field_info in list(config_eos.model_fields.items()) + list(
config_eos.model_computed_fields.items()
):
def extract_nested_models(
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
) -> None:
regular_field = isinstance(subfield_info, FieldInfo)
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
if subtype in inner_types:
return
nested_types = resolve_nested_types(subtype, [])
found_basic = False
for nested_type, nested_parent_types in nested_types:
if not isinstance(nested_type, type) or not issubclass(nested_type, PydanticBaseModel):
if found_basic:
continue
config = {} config = {}
config["name"] = field_name config["name"] = ".".join(parent_types)
config["value"] = getattr(config_eos, field_name) try:
config["default"] = config_eos.model_fields[field_name].default config["value"] = reduce(getattr, [config_eos] + parent_types)
config["description"] = config_eos.model_fields[field_name].description except AttributeError:
# Parent value(s) are not set in current config
config["value"] = ""
config["default"] = get_default_value(subfield_info, regular_field)
config["description"] = (
subfield_info.description if subfield_info.description else ""
)
configs.append(config) 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])
configs = sorted(configs, key=lambda x: x["name"])
app = FastHTML() app, rt = fast_app(
rt = app.route secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"),
)
def config_table() -> Table: def config_table() -> Table:
@@ -96,10 +168,10 @@ def main() -> None:
it starts the EOSdash server with the specified configurations. it starts the EOSdash server with the specified configurations.
Command-line Arguments: Command-line Arguments:
--host (str): Host for the EOSdash server (default: value from config_eos). --host (str): Host for the EOSdash server (default: value from config).
--port (int): Port for the EOSdash server (default: value from config_eos). --port (int): Port for the EOSdash server (default: value from config).
--eos-host (str): Host for the EOS server (default: value from config_eos). --eos-host (str): Host for the EOS server (default: value from config).
--eos-port (int): Port for the EOS server (default: value from config_eos). --eos-port (int): Port for the EOS server (default: value from config).
--log_level (str): Log level for the server. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info"). --log_level (str): Log level for the server. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info").
--access_log (bool): Enable or disable access log. Options: True or False (default: False). --access_log (bool): Enable or disable access log. Options: True or False (default: False).
--reload (bool): Enable or disable auto-reload. Useful for development. Options: True or False (default: False). --reload (bool): Enable or disable auto-reload. Useful for development. Options: True or False (default: False).
@@ -110,28 +182,28 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--host", "--host",
type=str, type=str,
default=str(config_eos.server_eosdash_host), default=str(config_eos.server.eosdash_host),
help="Host for the EOSdash server (default: value from config_eos)", help="Host for the EOSdash server (default: value from config)",
) )
parser.add_argument( parser.add_argument(
"--port", "--port",
type=int, type=int,
default=config_eos.server_eosdash_port, default=config_eos.server.eosdash_port,
help="Port for the EOSdash server (default: value from config_eos)", help="Port for the EOSdash server (default: value from config)",
) )
# EOS Host and port arguments with defaults from config_eos # EOS Host and port arguments with defaults from config_eos
parser.add_argument( parser.add_argument(
"--eos-host", "--eos-host",
type=str, type=str,
default=str(config_eos.server_eos_host), default=str(config_eos.server.host),
help="Host for the EOS server (default: value from config_eos)", help="Host for the EOS server (default: value from config)",
) )
parser.add_argument( parser.add_argument(
"--eos-port", "--eos-port",
type=int, type=int,
default=config_eos.server_eos_port, default=config_eos.server.port,
help="Port for the EOS server (default: value from config_eos)", help="Port for the EOS server (default: value from config)",
) )
# Optional arguments for log_level, access_log, and reload # Optional arguments for log_level, access_log, and reload
@@ -159,7 +231,7 @@ def main() -> None:
try: try:
run_eosdash(args.host, args.port, args.log_level, args.access_log, args.reload) run_eosdash(args.host, args.port, args.log_level, args.access_log, args.reload)
except: except:
exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -11,28 +11,24 @@ logger = get_logger(__name__)
class ServerCommonSettings(SettingsBaseModel): class ServerCommonSettings(SettingsBaseModel):
"""Common server settings. """Server Configuration.
Attributes: Attributes:
To be added To be added
""" """
server_eos_host: Optional[IPvAnyAddress] = Field( host: Optional[IPvAnyAddress] = Field(default="0.0.0.0", description="EOS server IP address.")
default="0.0.0.0", description="EOS server IP address." port: Optional[int] = Field(default=8503, description="EOS server IP port number.")
) verbose: Optional[bool] = Field(default=False, description="Enable debug output")
server_eos_port: Optional[int] = Field(default=8503, description="EOS server IP port number.") startup_eosdash: Optional[bool] = Field(
server_eos_verbose: Optional[bool] = Field(default=False, description="Enable debug output")
server_eos_startup_eosdash: Optional[bool] = Field(
default=True, description="EOS server to start EOSdash server." default=True, description="EOS server to start EOSdash server."
) )
server_eosdash_host: Optional[IPvAnyAddress] = Field( eosdash_host: Optional[IPvAnyAddress] = Field(
default="0.0.0.0", description="EOSdash server IP address." default="0.0.0.0", description="EOSdash server IP address."
) )
server_eosdash_port: Optional[int] = Field( eosdash_port: Optional[int] = Field(default=8504, description="EOSdash server IP port number.")
default=8504, description="EOSdash server IP port number."
)
@field_validator("server_eos_port", "server_eosdash_port") @field_validator("port", "eosdash_port")
def validate_server_port(cls, value: Optional[int]) -> Optional[int]: def validate_server_port(cls, value: Optional[int]) -> Optional[int]:
if value is not None and not (1024 <= value <= 49151): if value is not None and not (1024 <= value <= 49151):
raise ValueError("Server port number must be between 1024 and 49151.") raise ValueError("Server port number must be between 1024 and 49151.")

View File

@@ -329,9 +329,9 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
# File already available # File already available
cache_file_obj = cache_item.cache_file cache_file_obj = cache_item.cache_file
else: else:
self.config.data_cache_path.mkdir(parents=True, exist_ok=True) self.config.general.data_cache_path.mkdir(parents=True, exist_ok=True)
cache_file_obj = tempfile.NamedTemporaryFile( cache_file_obj = tempfile.NamedTemporaryFile(
mode=mode, delete=delete, suffix=suffix, dir=self.config.data_cache_path mode=mode, delete=delete, suffix=suffix, dir=self.config.general.data_cache_path
) )
self._store[cache_file_key] = CacheFileRecord( self._store[cache_file_key] = CacheFileRecord(
cache_file=cache_file_obj, cache_file=cache_file_obj,

View File

@@ -0,0 +1,42 @@
from typing import Any
from pydantic.fields import FieldInfo
from akkudoktoreos.core.pydantic import PydanticBaseModel
def get_example_or_default(field_name: str, field_info: FieldInfo, example_ix: int) -> Any:
"""Generate a default value for a field, considering constraints."""
if field_info.examples is not None:
try:
return field_info.examples[example_ix]
except IndexError:
return field_info.examples[-1]
if field_info.default is not None:
return field_info.default
raise NotImplementedError(f"No default or example provided '{field_name}': {field_info}")
def get_model_structure_from_examples(
model_class: type[PydanticBaseModel], multiple: bool
) -> list[dict[str, Any]]:
"""Create a model instance with default or example values, respecting constraints."""
example_max_length = 1
# Get first field with examples (non-default) to get example_max_length
if multiple:
for _, field_info in model_class.model_fields.items():
if field_info.examples is not None:
example_max_length = len(field_info.examples)
break
example_data: list[dict[str, Any]] = [{} for _ in range(example_max_length)]
for field_name, field_info in model_class.model_fields.items():
for example_ix in range(example_max_length):
example_data[example_ix][field_name] = get_example_or_default(
field_name, field_info, example_ix
)
return example_data

View File

@@ -10,6 +10,8 @@ logger = get_logger(__name__)
class UtilsCommonSettings(SettingsBaseModel): class UtilsCommonSettings(SettingsBaseModel):
"""Utils Configuration."""
pass pass
@@ -47,6 +49,6 @@ class NumpyEncoder(json.JSONEncoder):
# # Example usage # # Example usage
# start_date = datetime.datetime(2024, 3, 31) # Date of the DST change # start_date = datetime.datetime(2024, 3, 31) # Date of the DST change
# if ist_dst_wechsel(start_date): # if ist_dst_wechsel(start_date):
# prediction_hours = 23 # Adjust to 23 hours for DST change days # hours = 23 # Adjust to 23 hours for DST change days
# else: # else:
# prediction_hours = 24 # Default value for days without DST change # hours = 24 # Default value for days without DST change

View File

@@ -24,7 +24,12 @@ matplotlib.use(
class VisualizationReport(ConfigMixin): class VisualizationReport(ConfigMixin):
def __init__(self, filename: str = "visualization_results.pdf", version: str = "0.0.1") -> None: def __init__(
self,
filename: str = "visualization_results.pdf",
version: str = "0.0.1",
create_img: bool = True,
) -> None:
# Initialize the report with a given filename and empty groups # Initialize the report with a given filename and empty groups
self.filename = filename self.filename = filename
self.groups: list[list[Callable[[], None]]] = [] # Store groups of charts self.groups: list[list[Callable[[], None]]] = [] # Store groups of charts
@@ -34,12 +39,23 @@ class VisualizationReport(ConfigMixin):
self.pdf_pages = PdfPages(filename, metadata={}) # Initialize PdfPages without metadata self.pdf_pages = PdfPages(filename, metadata={}) # Initialize PdfPages without metadata
self.version = version # overwrite version as test for constant output of pdf for test self.version = version # overwrite version as test for constant output of pdf for test
self.current_time = to_datetime( self.current_time = to_datetime(
as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.timezone as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.general.timezone
) )
self.create_img = create_img
def add_chart_to_group(self, chart_func: Callable[[], None]) -> None: def add_chart_to_group(self, chart_func: Callable[[], None], title: str | None) -> None:
"""Add a chart function to the current group.""" """Add a chart function to the current group and save it as a PNG and SVG."""
self.current_group.append(chart_func) self.current_group.append(chart_func)
if self.create_img and title:
server_output_dir = self.config.general.data_cache_path
server_output_dir.mkdir(parents=True, exist_ok=True)
fig, ax = plt.subplots()
chart_func()
plt.tight_layout() # Adjust the layout to ensure titles are not cut off
sanitized_title = "".join(c if c.isalnum() else "_" for c in title)
chart_filename_base = os.path.join(server_output_dir, f"chart_{sanitized_title}")
fig.savefig(f"{chart_filename_base}.svg")
plt.close(fig)
def finalize_group(self) -> None: def finalize_group(self) -> None:
"""Finalize the current group and prepare for a new group.""" """Finalize the current group and prepare for a new group."""
@@ -51,7 +67,7 @@ class VisualizationReport(ConfigMixin):
def _initialize_pdf(self) -> None: def _initialize_pdf(self) -> None:
"""Create the output directory if it doesn't exist and initialize the PDF.""" """Create the output directory if it doesn't exist and initialize the PDF."""
output_dir = self.config.data_output_path output_dir = self.config.general.data_output_path
# If self.filename is already a valid path, use it; otherwise, combine it with output_dir # If self.filename is already a valid path, use it; otherwise, combine it with output_dir
if os.path.isabs(self.filename): if os.path.isabs(self.filename):
@@ -173,7 +189,7 @@ class VisualizationReport(ConfigMixin):
plt.grid(True) plt.grid(True)
# Add vertical line for the current date if within the axis range # Add vertical line for the current date if within the axis range
current_time = pendulum.now(self.config.timezone) current_time = pendulum.now(self.config.general.timezone)
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]: if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
plt.axvline(current_time, color="r", linestyle="--", label="Now") plt.axvline(current_time, color="r", linestyle="--", label="Now")
plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom") plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom")
@@ -195,7 +211,7 @@ class VisualizationReport(ConfigMixin):
# Ensure ax1 and ax2 are aligned # Ensure ax1 and ax2 are aligned
# assert ax1.get_xlim() == ax2.get_xlim(), "ax1 and ax2 are not aligned" # assert ax1.get_xlim() == ax2.get_xlim(), "ax1 and ax2 are not aligned"
self.add_chart_to_group(chart) # Add chart function to current group self.add_chart_to_group(chart, title) # Add chart function to current group
def create_line_chart( def create_line_chart(
self, self,
@@ -256,7 +272,7 @@ class VisualizationReport(ConfigMixin):
plt.grid(True) # Show grid plt.grid(True) # Show grid
plt.xlim(x[0] - 0.5, x[-1] + 0.5) # Adjust x-limits plt.xlim(x[0] - 0.5, x[-1] + 0.5) # Adjust x-limits
self.add_chart_to_group(chart) # Add chart function to current group self.add_chart_to_group(chart, title) # Add chart function to current group
def create_scatter_plot( def create_scatter_plot(
self, self,
@@ -278,7 +294,7 @@ class VisualizationReport(ConfigMixin):
plt.colorbar(scatter, label="Constraint") # Add colorbar if color data is provided plt.colorbar(scatter, label="Constraint") # Add colorbar if color data is provided
plt.grid(True) # Show grid plt.grid(True) # Show grid
self.add_chart_to_group(chart) # Add chart function to current group self.add_chart_to_group(chart, title) # Add chart function to current group
def create_bar_chart( def create_bar_chart(
self, self,
@@ -328,7 +344,7 @@ class VisualizationReport(ConfigMixin):
plt.grid(True, zorder=0) # Show grid in the background plt.grid(True, zorder=0) # Show grid in the background
plt.xlim(-0.5, len(labels) - 0.5) # Set x-axis limits plt.xlim(-0.5, len(labels) - 0.5) # Set x-axis limits
self.add_chart_to_group(chart) # Add chart function to current group self.add_chart_to_group(chart, title) # Add chart function to current group
def create_violin_plot( def create_violin_plot(
self, data_list: list[np.ndarray], labels: list[str], title: str, xlabel: str, ylabel: str self, data_list: list[np.ndarray], labels: list[str], title: str, xlabel: str, ylabel: str
@@ -343,7 +359,7 @@ class VisualizationReport(ConfigMixin):
plt.ylabel(ylabel) # Set y-axis label plt.ylabel(ylabel) # Set y-axis label
plt.grid(True) # Show grid plt.grid(True) # Show grid
self.add_chart_to_group(chart) # Add chart function to current group self.add_chart_to_group(chart, title) # Add chart function to current group
def add_text_page(self, text: str, title: Optional[str] = None, fontsize: int = 12) -> None: def add_text_page(self, text: str, title: Optional[str] = None, fontsize: int = 12) -> None:
"""Add a page with text content to the PDF.""" """Add a page with text content to the PDF."""
@@ -362,7 +378,7 @@ class VisualizationReport(ConfigMixin):
self.pdf_pages.savefig(fig) # Save the figure as a page in the PDF self.pdf_pages.savefig(fig) # Save the figure as a page in the PDF
plt.close(fig) # Close the figure to free up memory plt.close(fig) # Close the figure to free up memory
self.add_chart_to_group(chart) # Treat the text page as a "chart" in the group self.add_chart_to_group(chart, title) # Treat the text page as a "chart" in the group
def add_json_page( def add_json_page(
self, json_obj: dict, title: Optional[str] = None, fontsize: int = 12 self, json_obj: dict, title: Optional[str] = None, fontsize: int = 12
@@ -400,7 +416,7 @@ class VisualizationReport(ConfigMixin):
self.pdf_pages.savefig(fig) # Save the figure as a page in the PDF self.pdf_pages.savefig(fig) # Save the figure as a page in the PDF
plt.close(fig) # Close the figure to free up memory plt.close(fig) # Close the figure to free up memory
self.add_chart_to_group(chart) # Treat the JSON page as a "chart" in the group self.add_chart_to_group(chart, title) # Treat the JSON page as a "chart" in the group
def generate_pdf(self) -> None: def generate_pdf(self) -> None:
"""Generate the PDF report with all the added chart groups.""" """Generate the PDF report with all the added chart groups."""
@@ -419,7 +435,7 @@ def prepare_visualize(
start_hour: Optional[int] = 0, start_hour: Optional[int] = 0,
) -> None: ) -> None:
report = VisualizationReport(filename) report = VisualizationReport(filename)
next_full_hour_date = pendulum.now(report.config.timezone).start_of("hour").add(hours=1) next_full_hour_date = pendulum.now(report.config.general.timezone).start_of("hour").add(hours=1)
# Group 1: # Group 1:
report.create_line_chart_date( report.create_line_chart_date(
next_full_hour_date, # start_date next_full_hour_date, # start_date
@@ -503,7 +519,7 @@ def prepare_visualize(
report.create_line_chart_date( report.create_line_chart_date(
next_full_hour_date, # start_date next_full_hour_date, # start_date
[parameters.ems.strompreis_euro_pro_wh], [parameters.ems.strompreis_euro_pro_wh],
# title="Electricity Price", # not enough space title="Electricity Price",
# xlabel="Date", # not enough space # xlabel="Date", # not enough space
ylabel="Electricity Price (€/Wh)", ylabel="Electricity Price (€/Wh)",
x2label=None, # not enough space x2label=None, # not enough space
@@ -538,7 +554,7 @@ def prepare_visualize(
report.create_scatter_plot( report.create_scatter_plot(
extra_data["verluste"], extra_data["verluste"],
extra_data["bilanz"], extra_data["bilanz"],
title="", title="Scatter Plot",
xlabel="losses", xlabel="losses",
ylabel="balance", ylabel="balance",
c=extra_data["nebenbedingung"], c=extra_data["nebenbedingung"],

View File

@@ -64,6 +64,25 @@ def config_mixin(config_eos):
yield config_mixin_patch yield config_mixin_patch
@pytest.fixture
def devices_eos(config_mixin):
from akkudoktoreos.devices.devices import get_devices
devices = get_devices()
print("devices_eos reset!")
devices.reset()
return devices
@pytest.fixture
def devices_mixin(devices_eos):
with patch(
"akkudoktoreos.core.coreabc.DevicesMixin.devices", new_callable=PropertyMock
) as devices_mixin_patch:
devices_mixin_patch.return_value = devices_eos
yield devices_mixin_patch
# Test if test has side effect of writing to system (user) config file # Test if test has side effect of writing to system (user) config file
# Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json) # Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -114,20 +133,24 @@ def config_eos(
monkeypatch, monkeypatch,
) -> ConfigEOS: ) -> ConfigEOS:
"""Fixture to reset EOS config to default values.""" """Fixture to reset EOS config to default values."""
monkeypatch.setenv("data_cache_subpath", str(config_default_dirs[-1] / "data/cache")) monkeypatch.setenv(
monkeypatch.setenv("data_output_subpath", str(config_default_dirs[-1] / "data/output")) "EOS_CONFIG__DATA_CACHE_SUBPATH", str(config_default_dirs[-1] / "data/cache")
)
monkeypatch.setenv(
"EOS_CONFIG__DATA_OUTPUT_SUBPATH", str(config_default_dirs[-1] / "data/output")
)
config_file = config_default_dirs[0] / ConfigEOS.CONFIG_FILE_NAME config_file = config_default_dirs[0] / ConfigEOS.CONFIG_FILE_NAME
config_file_cwd = config_default_dirs[1] / ConfigEOS.CONFIG_FILE_NAME config_file_cwd = config_default_dirs[1] / ConfigEOS.CONFIG_FILE_NAME
assert not config_file.exists() assert not config_file.exists()
assert not config_file_cwd.exists() assert not config_file_cwd.exists()
config_eos = get_config() config_eos = get_config()
config_eos.reset_settings() config_eos.reset_settings()
assert config_file == config_eos.config_file_path assert config_file == config_eos.general.config_file_path
assert config_file.exists() assert config_file.exists()
assert not config_file_cwd.exists() assert not config_file_cwd.exists()
assert config_default_dirs[-1] / "data" == config_eos.data_folder_path assert config_default_dirs[-1] / "data" == config_eos.general.data_folder_path
assert config_default_dirs[-1] / "data/cache" == config_eos.data_cache_path assert config_default_dirs[-1] / "data/cache" == config_eos.general.data_cache_path
assert config_default_dirs[-1] / "data/output" == config_eos.data_output_path assert config_default_dirs[-1] / "data/output" == config_eos.general.data_output_path
return config_eos return config_eos
@@ -166,6 +189,7 @@ def server(xprocess, config_eos, config_default_dirs):
# Set environment before any subprocess run, to keep custom config dir # Set environment before any subprocess run, to keep custom config dir
env = os.environ.copy() env = os.environ.copy()
env["EOS_DIR"] = str(config_default_dirs[-1]) env["EOS_DIR"] = str(config_default_dirs[-1])
project_dir = config_eos.package_root_path
# assure server to be installed # assure server to be installed
try: try:
@@ -175,9 +199,9 @@ def server(xprocess, config_eos, config_default_dirs):
env=env, env=env,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=project_dir,
) )
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
project_dir = config_eos.package_root_path
subprocess.run( subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", project_dir], [sys.executable, "-m", "pip", "install", "-e", project_dir],
check=True, check=True,

View File

@@ -7,13 +7,15 @@ from akkudoktoreos.devices.battery import Battery, SolarPanelBatteryParameters
@pytest.fixture @pytest.fixture
def setup_pv_battery(): def setup_pv_battery():
params = SolarPanelBatteryParameters( params = SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=10000, capacity_wh=10000,
initial_soc_percentage=50, initial_soc_percentage=50,
min_soc_percentage=20, min_soc_percentage=20,
max_soc_percentage=80, max_soc_percentage=80,
max_charge_power_w=8000, max_charge_power_w=8000,
hours=24,
) )
battery = Battery(params, hours=24) battery = Battery(params)
battery.reset() battery.reset()
return battery return battery
@@ -113,7 +115,6 @@ def test_soc_limits(setup_pv_battery):
def test_max_charge_power_w(setup_pv_battery): def test_max_charge_power_w(setup_pv_battery):
battery = setup_pv_battery battery = setup_pv_battery
battery.setup()
assert ( assert (
battery.parameters.max_charge_power_w == 8000 battery.parameters.max_charge_power_w == 8000
), "Default max charge power should be 5000W, We ask for 8000W here" ), "Default max charge power should be 5000W, We ask for 8000W here"
@@ -121,7 +122,6 @@ def test_max_charge_power_w(setup_pv_battery):
def test_charge_energy_within_limits(setup_pv_battery): def test_charge_energy_within_limits(setup_pv_battery):
battery = setup_pv_battery battery = setup_pv_battery
battery.setup()
initial_soc_wh = battery.soc_wh initial_soc_wh = battery.soc_wh
charged_wh, losses_wh = battery.charge_energy(wh=4000, hour=1) charged_wh, losses_wh = battery.charge_energy(wh=4000, hour=1)
@@ -134,7 +134,6 @@ def test_charge_energy_within_limits(setup_pv_battery):
def test_charge_energy_exceeds_capacity(setup_pv_battery): def test_charge_energy_exceeds_capacity(setup_pv_battery):
battery = setup_pv_battery battery = setup_pv_battery
battery.setup()
initial_soc_wh = battery.soc_wh initial_soc_wh = battery.soc_wh
# Try to overcharge beyond max capacity # Try to overcharge beyond max capacity
@@ -149,7 +148,6 @@ def test_charge_energy_exceeds_capacity(setup_pv_battery):
def test_charge_energy_not_allowed_hour(setup_pv_battery): def test_charge_energy_not_allowed_hour(setup_pv_battery):
battery = setup_pv_battery battery = setup_pv_battery
battery.setup()
# Disable charging for all hours # Disable charging for all hours
battery.set_charge_per_hour(np.zeros(battery.hours)) battery.set_charge_per_hour(np.zeros(battery.hours))
@@ -165,7 +163,6 @@ def test_charge_energy_not_allowed_hour(setup_pv_battery):
def test_charge_energy_relative_power(setup_pv_battery): def test_charge_energy_relative_power(setup_pv_battery):
battery = setup_pv_battery battery = setup_pv_battery
battery.setup()
relative_power = 0.5 # 50% of max charge power relative_power = 0.5 # 50% of max charge power
charged_wh, losses_wh = battery.charge_energy(wh=None, hour=4, relative_power=relative_power) charged_wh, losses_wh = battery.charge_energy(wh=None, hour=4, relative_power=relative_power)
@@ -183,13 +180,15 @@ def setup_car_battery():
from akkudoktoreos.devices.battery import ElectricVehicleParameters from akkudoktoreos.devices.battery import ElectricVehicleParameters
params = ElectricVehicleParameters( params = ElectricVehicleParameters(
device_id="ev1",
capacity_wh=40000, capacity_wh=40000,
initial_soc_percentage=60, initial_soc_percentage=60,
min_soc_percentage=10, min_soc_percentage=10,
max_soc_percentage=90, max_soc_percentage=90,
max_charge_power_w=7000, max_charge_power_w=7000,
hours=24,
) )
battery = Battery(params, hours=24) battery = Battery(params)
battery.reset() battery.reset()
return battery return battery

View File

@@ -1,5 +1,3 @@
from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
@@ -16,58 +14,58 @@ from akkudoktoreos.devices.battery import (
) )
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Inverter, InverterParameters from akkudoktoreos.devices.inverter import Inverter, InverterParameters
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
start_hour = 1 start_hour = 1
# Example initialization of necessary components # Example initialization of necessary components
@pytest.fixture @pytest.fixture
def create_ems_instance(config_eos) -> EnergieManagementSystem: def create_ems_instance(devices_eos, config_eos) -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters.""" """Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Assure configuration holds the correct values # Assure configuration holds the correct values
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24}) config_eos.merge_settings_from_dict(
assert config_eos.prediction_hours is not None {"prediction": {"hours": 48}, "optimization": {"hours": 24}}
)
assert config_eos.prediction.hours == 48
# Initialize the battery and the inverter # Initialize the battery and the inverter
akku = Battery( akku = Battery(
SolarPanelBatteryParameters( SolarPanelBatteryParameters(
capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10 device_id="battery1",
), capacity_wh=5000,
hours=config_eos.prediction_hours, initial_soc_percentage=80,
min_soc_percentage=10,
) )
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
sc = SelfConsumptionProbabilityInterpolator(
Path(__file__).parent.resolve()
/ ".."
/ "src"
/ "akkudoktoreos"
/ "data"
/ "regular_grid_interpolator.pkl"
) )
akku.reset() akku.reset()
inverter = Inverter(sc, InverterParameters(max_power_wh=10000), akku) devices_eos.add_device(akku)
inverter = Inverter(
InverterParameters(device_id="inverter1", max_power_wh=10000, battery_id=akku.device_id)
)
devices_eos.add_device(inverter)
# Household device (currently not used, set to None) # Household device (currently not used, set to None)
home_appliance = HomeAppliance( home_appliance = HomeAppliance(
HomeApplianceParameters( HomeApplianceParameters(
device_id="dishwasher1",
consumption_wh=2000, consumption_wh=2000,
duration_h=2, duration_h=2,
), ),
hours=config_eos.prediction_hours,
) )
home_appliance.set_starting_time(2) home_appliance.set_starting_time(2)
devices_eos.add_device(home_appliance)
# Example initialization of electric car battery # Example initialization of electric car battery
eauto = Battery( eauto = Battery(
ElectricVehicleParameters( ElectricVehicleParameters(
capacity_wh=26400, initial_soc_percentage=10, min_soc_percentage=10 device_id="ev1", capacity_wh=26400, initial_soc_percentage=10, min_soc_percentage=10
), ),
hours=config_eos.prediction_hours,
) )
eauto.set_charge_per_hour(np.full(config_eos.prediction_hours, 1)) eauto.set_charge_per_hour(np.full(config_eos.prediction.hours, 1))
devices_eos.add_device(eauto)
devices_eos.post_setup()
# Parameters based on previous example data # Parameters based on previous example data
pv_prognose_wh = [ pv_prognose_wh = [

View File

@@ -1,11 +1,10 @@
from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from akkudoktoreos.core.ems import ( from akkudoktoreos.core.ems import (
EnergieManagementSystem, EnergieManagementSystem,
EnergieManagementSystemParameters, EnergieManagementSystemParameters,
SimulationResult,
get_ems, get_ems,
) )
from akkudoktoreos.devices.battery import ( from akkudoktoreos.devices.battery import (
@@ -15,64 +14,61 @@ from akkudoktoreos.devices.battery import (
) )
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Inverter, InverterParameters from akkudoktoreos.devices.inverter import Inverter, InverterParameters
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
start_hour = 0 start_hour = 0
# Example initialization of necessary components # Example initialization of necessary components
@pytest.fixture @pytest.fixture
def create_ems_instance(config_eos) -> EnergieManagementSystem: def create_ems_instance(devices_eos, config_eos) -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters.""" """Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Assure configuration holds the correct values # Assure configuration holds the correct values
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24}) config_eos.merge_settings_from_dict(
assert config_eos.prediction_hours is not None {"prediction": {"hours": 48}, "optimization": {"hours": 24}}
)
assert config_eos.prediction.hours == 48
# Initialize the battery and the inverter # Initialize the battery and the inverter
akku = Battery( akku = Battery(
SolarPanelBatteryParameters( SolarPanelBatteryParameters(
capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10 device_id="pv1", capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
),
hours=config_eos.prediction_hours,
) )
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
sc = SelfConsumptionProbabilityInterpolator(
Path(__file__).parent.resolve()
/ ".."
/ "src"
/ "akkudoktoreos"
/ "data"
/ "regular_grid_interpolator.pkl"
) )
akku.reset() akku.reset()
inverter = Inverter(sc, InverterParameters(max_power_wh=10000), akku) devices_eos.add_device(akku)
inverter = Inverter(
InverterParameters(device_id="iv1", max_power_wh=10000, battery_id=akku.device_id)
)
devices_eos.add_device(inverter)
# Household device (currently not used, set to None) # Household device (currently not used, set to None)
home_appliance = HomeAppliance( home_appliance = HomeAppliance(
HomeApplianceParameters( HomeApplianceParameters(
device_id="dishwasher1",
consumption_wh=2000, consumption_wh=2000,
duration_h=2, duration_h=2,
), )
hours=config_eos.prediction_hours,
) )
home_appliance.set_starting_time(2) home_appliance.set_starting_time(2)
devices_eos.add_device(home_appliance)
# Example initialization of electric car battery # Example initialization of electric car battery
eauto = Battery( eauto = Battery(
ElectricVehicleParameters( ElectricVehicleParameters(
capacity_wh=26400, initial_soc_percentage=100, min_soc_percentage=100 device_id="ev1", capacity_wh=26400, initial_soc_percentage=100, min_soc_percentage=100
), ),
hours=config_eos.prediction_hours,
) )
devices_eos.add_device(eauto)
devices_eos.post_setup()
# Parameters based on previous example data # Parameters based on previous example data
pv_prognose_wh = [0.0] * config_eos.prediction_hours pv_prognose_wh = [0.0] * config_eos.prediction.hours
pv_prognose_wh[10] = 5000.0 pv_prognose_wh[10] = 5000.0
pv_prognose_wh[11] = 5000.0 pv_prognose_wh[11] = 5000.0
strompreis_euro_pro_wh = [0.001] * config_eos.prediction_hours strompreis_euro_pro_wh = [0.001] * config_eos.prediction.hours
strompreis_euro_pro_wh[0:10] = [0.00001] * 10 strompreis_euro_pro_wh[0:10] = [0.00001] * 10
strompreis_euro_pro_wh[11:15] = [0.00005] * 4 strompreis_euro_pro_wh[11:15] = [0.00005] * 4
strompreis_euro_pro_wh[20] = 0.00001 strompreis_euro_pro_wh[20] = 0.00001
@@ -146,10 +142,10 @@ def create_ems_instance(config_eos) -> EnergieManagementSystem:
home_appliance=home_appliance, home_appliance=home_appliance,
) )
ac = np.full(config_eos.prediction_hours, 0.0) ac = np.full(config_eos.prediction.hours, 0.0)
ac[20] = 1 ac[20] = 1
ems.set_akku_ac_charge_hours(ac) ems.set_akku_ac_charge_hours(ac)
dc = np.full(config_eos.prediction_hours, 0.0) dc = np.full(config_eos.prediction.hours, 0.0)
dc[11] = 1 dc[11] = 1
ems.set_akku_dc_charge_hours(dc) ems.set_akku_dc_charge_hours(dc)
@@ -182,6 +178,7 @@ def test_simulation(create_ems_instance):
# Assertions to validate results # Assertions to validate results
assert result is not None, "Result should not be None" assert result is not None, "Result should not be None"
assert isinstance(result, dict), "Result should be a dictionary" assert isinstance(result, dict), "Result should be a dictionary"
assert SimulationResult(**result) is not None
assert "Last_Wh_pro_Stunde" in result, "Result should contain 'Last_Wh_pro_Stunde'" assert "Last_Wh_pro_Stunde" in result, "Result should contain 'Last_Wh_pro_Stunde'"
""" """
@@ -240,7 +237,7 @@ def test_simulation(create_ems_instance):
assert ( assert (
abs(result["Netzeinspeisung_Wh_pro_Stunde"][10] - 3946.93) < 1e-3 abs(result["Netzeinspeisung_Wh_pro_Stunde"][10] - 3946.93) < 1e-3
), "'Netzeinspeisung_Wh_pro_Stunde[11]' should be 4000." ), "'Netzeinspeisung_Wh_pro_Stunde[11]' should be 3946.93."
assert ( assert (
abs(result["Netzeinspeisung_Wh_pro_Stunde"][11] - 0.0) < 1e-3 abs(result["Netzeinspeisung_Wh_pro_Stunde"][11] - 0.0) < 1e-3
@@ -251,6 +248,78 @@ def test_simulation(create_ems_instance):
), "'akku_soc_pro_stunde[20]' should be 10." ), "'akku_soc_pro_stunde[20]' should be 10."
assert ( assert (
abs(result["Last_Wh_pro_Stunde"][20] - 6050.98) < 1e-3 abs(result["Last_Wh_pro_Stunde"][20] - 6050.98) < 1e-3
), "'Netzeinspeisung_Wh_pro_Stunde[11]' should be 0.0." ), "'Last_Wh_pro_Stunde[20]' should be 6050.98."
print("All tests passed successfully.") print("All tests passed successfully.")
def test_set_parameters(create_ems_instance):
"""Test the set_parameters method of EnergieManagementSystem."""
ems = create_ems_instance
# Check if parameters are set correctly
assert ems.load_energy_array is not None, "load_energy_array should not be None"
assert ems.pv_prediction_wh is not None, "pv_prediction_wh should not be None"
assert ems.elect_price_hourly is not None, "elect_price_hourly should not be None"
assert (
ems.elect_revenue_per_hour_arr is not None
), "elect_revenue_per_hour_arr should not be None"
def test_set_akku_discharge_hours(create_ems_instance):
"""Test the set_akku_discharge_hours method of EnergieManagementSystem."""
ems = create_ems_instance
discharge_hours = np.full(ems.config.prediction.hours, 1.0)
ems.set_akku_discharge_hours(discharge_hours)
assert np.array_equal(
ems.battery.discharge_array, discharge_hours
), "Discharge hours should be set correctly"
def test_set_akku_ac_charge_hours(create_ems_instance):
"""Test the set_akku_ac_charge_hours method of EnergieManagementSystem."""
ems = create_ems_instance
ac_charge_hours = np.full(ems.config.prediction.hours, 1.0)
ems.set_akku_ac_charge_hours(ac_charge_hours)
assert np.array_equal(
ems.ac_charge_hours, ac_charge_hours
), "AC charge hours should be set correctly"
def test_set_akku_dc_charge_hours(create_ems_instance):
"""Test the set_akku_dc_charge_hours method of EnergieManagementSystem."""
ems = create_ems_instance
dc_charge_hours = np.full(ems.config.prediction.hours, 1.0)
ems.set_akku_dc_charge_hours(dc_charge_hours)
assert np.array_equal(
ems.dc_charge_hours, dc_charge_hours
), "DC charge hours should be set correctly"
def test_set_ev_charge_hours(create_ems_instance):
"""Test the set_ev_charge_hours method of EnergieManagementSystem."""
ems = create_ems_instance
ev_charge_hours = np.full(ems.config.prediction.hours, 1.0)
ems.set_ev_charge_hours(ev_charge_hours)
assert np.array_equal(
ems.ev_charge_hours, ev_charge_hours
), "EV charge hours should be set correctly"
def test_reset(create_ems_instance):
"""Test the reset method of EnergieManagementSystem."""
ems = create_ems_instance
ems.reset()
assert ems.ev.current_soc_percentage() == 100, "EV SOC should be reset to initial value"
assert (
ems.battery.current_soc_percentage() == 80
), "Battery SOC should be reset to initial value"
def test_simulate_start_now(create_ems_instance):
"""Test the simulate_start_now method of EnergieManagementSystem."""
ems = create_ems_instance
result = ems.simulate_start_now()
assert result is not None, "Result should not be None"
assert isinstance(result, dict), "Result should be a dictionary"
assert "Last_Wh_pro_Stunde" in result, "Result should contain 'Last_Wh_pro_Stunde'"

View File

@@ -49,7 +49,9 @@ def test_optimize(
): ):
"""Test optimierung_ems.""" """Test optimierung_ems."""
# Assure configuration holds the correct values # Assure configuration holds the correct values
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48}) config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"hours": 48}}
)
# Load input and output data # Load input and output data
file = DIR_TESTDATA / fn_in file = DIR_TESTDATA / fn_in

View File

@@ -3,8 +3,9 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from pydantic import ValidationError
from akkudoktoreos.config.config import ConfigEOS from akkudoktoreos.config.config import ConfigEOS, GeneralSettings
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -38,22 +39,26 @@ def test_config_constants(config_eos):
def test_computed_paths(config_eos): def test_computed_paths(config_eos):
"""Test computed paths for output and cache.""" """Test computed paths for output and cache."""
# Don't actually try to create the data folder
with patch("pathlib.Path.mkdir"):
config_eos.merge_settings_from_dict( config_eos.merge_settings_from_dict(
{ {
"general": {
"data_folder_path": "/base/data", "data_folder_path": "/base/data",
"data_output_subpath": "output", "data_output_subpath": "extra/output",
"data_cache_subpath": "cache", "data_cache_subpath": "somewhere/cache",
}
} }
) )
assert config_eos.data_output_path == Path("/base/data/output") assert config_eos.general.data_output_path == Path("/base/data/extra/output")
assert config_eos.data_cache_path == Path("/base/data/cache") assert config_eos.general.data_cache_path == Path("/base/data/somewhere/cache")
# reset settings so the config_eos fixture can verify the default paths # reset settings so the config_eos fixture can verify the default paths
config_eos.reset_settings() config_eos.reset_settings()
def test_singleton_behavior(config_eos, config_default_dirs): def test_singleton_behavior(config_eos, config_default_dirs):
"""Test that ConfigEOS behaves as a singleton.""" """Test that ConfigEOS behaves as a singleton."""
initial_cfg_file = config_eos.config_file_path initial_cfg_file = config_eos.general.config_file_path
with patch( with patch(
"akkudoktoreos.config.config.user_config_dir", return_value=str(config_default_dirs[0]) "akkudoktoreos.config.config.user_config_dir", return_value=str(config_default_dirs[0])
): ):
@@ -61,7 +66,7 @@ def test_singleton_behavior(config_eos, config_default_dirs):
instance2 = ConfigEOS() instance2 = ConfigEOS()
assert instance1 is config_eos assert instance1 is config_eos
assert instance1 is instance2 assert instance1 is instance2
assert instance1.config_file_path == initial_cfg_file assert instance1.general.config_file_path == initial_cfg_file
def test_default_config_path(config_eos, config_default_dirs): def test_default_config_path(config_eos, config_default_dirs):
@@ -82,13 +87,13 @@ def test_config_file_priority(config_default_dirs):
config_file = Path(config_default_dir_cwd) / ConfigEOS.CONFIG_FILE_NAME config_file = Path(config_default_dir_cwd) / ConfigEOS.CONFIG_FILE_NAME
config_file.write_text("{}") config_file.write_text("{}")
config_eos = get_config() config_eos = get_config()
assert config_eos.config_file_path == config_file assert config_eos.general.config_file_path == config_file
config_file = Path(config_default_dir_user) / ConfigEOS.CONFIG_FILE_NAME config_file = Path(config_default_dir_user) / ConfigEOS.CONFIG_FILE_NAME
config_file.parent.mkdir() config_file.parent.mkdir()
config_file.write_text("{}") config_file.write_text("{}")
config_eos = get_config() config_eos.update()
assert config_eos.config_file_path == config_file assert config_eos.general.config_file_path == config_file
@patch("akkudoktoreos.config.config.user_config_dir") @patch("akkudoktoreos.config.config.user_config_dir")
@@ -141,5 +146,69 @@ def test_config_copy(config_eos, monkeypatch):
assert not temp_config_file_path.exists() assert not temp_config_file_path.exists()
with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir): with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir):
assert config_eos._get_config_file_path() == (temp_config_file_path, False) assert config_eos._get_config_file_path() == (temp_config_file_path, False)
config_eos.from_config_file() config_eos.update()
assert temp_config_file_path.exists() assert temp_config_file_path.exists()
@pytest.mark.parametrize(
"latitude, longitude, expected_timezone",
[
(40.7128, -74.0060, "America/New_York"), # Valid latitude/longitude
(None, None, None), # No location
(51.5074, -0.1278, "Europe/London"), # Another valid location
],
)
def test_config_common_settings_valid(latitude, longitude, expected_timezone):
"""Test valid settings for GeneralSettings."""
general_settings = GeneralSettings(
latitude=latitude,
longitude=longitude,
)
assert general_settings.latitude == latitude
assert general_settings.longitude == longitude
assert general_settings.timezone == expected_timezone
@pytest.mark.parametrize(
"field_name, invalid_value, expected_error",
[
("latitude", -91.0, "Input should be greater than or equal to -90"),
("latitude", 91.0, "Input should be less than or equal to 90"),
("longitude", -181.0, "Input should be greater than or equal to -180"),
("longitude", 181.0, "Input should be less than or equal to 180"),
],
)
def test_config_common_settings_invalid(field_name, invalid_value, expected_error):
"""Test invalid settings for PredictionCommonSettings."""
valid_data = {
"latitude": 40.7128,
"longitude": -74.0060,
}
assert GeneralSettings(**valid_data) is not None
valid_data[field_name] = invalid_value
with pytest.raises(ValidationError, match=expected_error):
GeneralSettings(**valid_data)
def test_config_common_settings_no_location():
"""Test that timezone is None when latitude and longitude are not provided."""
settings = GeneralSettings(latitude=None, longitude=None)
assert settings.timezone is None
def test_config_common_settings_with_location():
"""Test that timezone is correctly computed when latitude and longitude are provided."""
settings = GeneralSettings(latitude=34.0522, longitude=-118.2437)
assert settings.timezone == "America/Los_Angeles"
def test_config_common_settings_timezone_none_when_coordinates_missing():
"""Test that timezone is None when latitude or longitude is missing."""
config_no_latitude = GeneralSettings(latitude=None, longitude=-74.0060)
config_no_longitude = GeneralSettings(latitude=40.7128, longitude=None)
config_no_coords = GeneralSettings(latitude=None, longitude=None)
assert config_no_latitude.timezone is None
assert config_no_longitude.timezone is None
assert config_no_coords.timezone is None

View File

@@ -535,7 +535,7 @@ class TestDataSequence:
json_str = sequence.to_json() json_str = sequence.to_json()
assert isinstance(json_str, str) assert isinstance(json_str, str)
assert "2023-11-06" in json_str assert "2023-11-06" in json_str
assert ":0.8" in json_str assert ": 0.8" in json_str
def test_from_json(self, sequence, sequence2): def test_from_json(self, sequence, sequence2):
json_str = sequence2.to_json() json_str = sequence2.to_json()

View File

@@ -86,7 +86,7 @@ def test_config_md_current(config_eos):
sys.path.insert(0, str(root_dir)) sys.path.insert(0, str(root_dir))
from scripts import generate_config_md from scripts import generate_config_md
config_md = generate_config_md.generate_config_md() config_md = generate_config_md.generate_config_md(config_eos)
with open(new_config_md_path, "w", encoding="utf8") as f_new: with open(new_config_md_path, "w", encoding="utf8") as f_new:
f_new.write(config_md) f_new.write(config_md)

View File

@@ -23,9 +23,10 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
@pytest.fixture @pytest.fixture
def elecprice_provider(monkeypatch): def provider(monkeypatch, config_eos):
"""Fixture to create a ElecPriceProvider instance.""" """Fixture to create a ElecPriceProvider instance."""
monkeypatch.setenv("elecprice_provider", "ElecPriceAkkudoktor") monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceAkkudoktor")
config_eos.reset_settings()
return ElecPriceAkkudoktor() return ElecPriceAkkudoktor()
@@ -48,17 +49,17 @@ def cache_store():
# ------------------------------------------------ # ------------------------------------------------
def test_singleton_instance(elecprice_provider): def test_singleton_instance(provider):
"""Test that ElecPriceForecast behaves as a singleton.""" """Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceAkkudoktor() another_instance = ElecPriceAkkudoktor()
assert elecprice_provider is another_instance assert provider is another_instance
def test_invalid_provider(elecprice_provider, monkeypatch): def test_invalid_provider(provider, monkeypatch):
"""Test requesting an unsupported elecprice_provider.""" """Test requesting an unsupported provider."""
monkeypatch.setenv("elecprice_provider", "<invalid>") monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
elecprice_provider.config.update() provider.config.reset_settings()
assert elecprice_provider.enabled() == False assert not provider.enabled()
# ------------------------------------------------ # ------------------------------------------------
@@ -67,16 +68,16 @@ def test_invalid_provider(elecprice_provider, monkeypatch):
@patch("akkudoktoreos.prediction.elecpriceakkudoktor.logger.error") @patch("akkudoktoreos.prediction.elecpriceakkudoktor.logger.error")
def test_validate_data_invalid_format(mock_logger, elecprice_provider): def test_validate_data_invalid_format(mock_logger, provider):
"""Test validation for invalid Akkudoktor data.""" """Test validation for invalid Akkudoktor data."""
invalid_data = '{"invalid": "data"}' invalid_data = '{"invalid": "data"}'
with pytest.raises(ValueError): with pytest.raises(ValueError):
elecprice_provider._validate_data(invalid_data) provider._validate_data(invalid_data)
mock_logger.assert_called_once_with(mock_logger.call_args[0][0]) mock_logger.assert_called_once_with(mock_logger.call_args[0][0])
@patch("requests.get") @patch("requests.get")
def test_request_forecast(mock_get, elecprice_provider, sample_akkudoktor_1_json): def test_request_forecast(mock_get, provider, sample_akkudoktor_1_json):
"""Test requesting forecast from Akkudoktor.""" """Test requesting forecast from Akkudoktor."""
# Mock response object # Mock response object
mock_response = Mock() mock_response = Mock()
@@ -85,10 +86,10 @@ def test_request_forecast(mock_get, elecprice_provider, sample_akkudoktor_1_json
mock_get.return_value = mock_response mock_get.return_value = mock_response
# Preset, as this is usually done by update() # Preset, as this is usually done by update()
elecprice_provider.config.update() provider.config.update()
# Test function # Test function
akkudoktor_data = elecprice_provider._request_forecast() akkudoktor_data = provider._request_forecast()
assert isinstance(akkudoktor_data, AkkudoktorElecPrice) assert isinstance(akkudoktor_data, AkkudoktorElecPrice)
assert akkudoktor_data.values[0] == AkkudoktorElecPriceValue( assert akkudoktor_data.values[0] == AkkudoktorElecPriceValue(
@@ -103,7 +104,7 @@ def test_request_forecast(mock_get, elecprice_provider, sample_akkudoktor_1_json
@patch("requests.get") @patch("requests.get")
def test_update_data(mock_get, elecprice_provider, sample_akkudoktor_1_json, cache_store): def test_update_data(mock_get, provider, sample_akkudoktor_1_json, cache_store):
"""Test fetching forecast from Akkudoktor.""" """Test fetching forecast from Akkudoktor."""
# Mock response object # Mock response object
mock_response = Mock() mock_response = Mock()
@@ -116,28 +117,28 @@ def test_update_data(mock_get, elecprice_provider, sample_akkudoktor_1_json, cac
# Call the method # Call the method
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
elecprice_provider.update_data(force_enable=True, force_update=True) provider.update_data(force_enable=True, force_update=True)
# Assert: Verify the result is as expected # Assert: Verify the result is as expected
mock_get.assert_called_once() mock_get.assert_called_once()
assert ( assert (
len(elecprice_provider) == 73 len(provider) == 73
) # we have 48 datasets in the api response, we want to know 48h into the future. The data we get has already 23h into the future so we need only 25h more. 48+25=73 ) # we have 48 datasets in the api response, we want to know 48h into the future. The data we get has already 23h into the future so we need only 25h more. 48+25=73
# Assert we get prediction_hours prioce values by resampling # Assert we get hours prioce values by resampling
np_price_array = elecprice_provider.key_to_array( np_price_array = provider.key_to_array(
key="elecprice_marketprice_wh", key="elecprice_marketprice_wh",
start_datetime=elecprice_provider.start_datetime, start_datetime=provider.start_datetime,
end_datetime=elecprice_provider.end_datetime, end_datetime=provider.end_datetime,
) )
assert len(np_price_array) == elecprice_provider.total_hours assert len(np_price_array) == provider.total_hours
# with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_2_JSON, "w") as f_out: # with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_2_JSON, "w") as f_out:
# f_out.write(elecprice_provider.to_json()) # f_out.write(provider.to_json())
@patch("requests.get") @patch("requests.get")
def test_update_data_with_incomplete_forecast(mock_get, elecprice_provider): def test_update_data_with_incomplete_forecast(mock_get, provider):
"""Test `_update_data` with incomplete or missing forecast data.""" """Test `_update_data` with incomplete or missing forecast data."""
incomplete_data: dict = {"meta": {}, "values": []} incomplete_data: dict = {"meta": {}, "values": []}
mock_response = Mock() mock_response = Mock()
@@ -145,7 +146,7 @@ def test_update_data_with_incomplete_forecast(mock_get, elecprice_provider):
mock_response.content = json.dumps(incomplete_data) mock_response.content = json.dumps(incomplete_data)
mock_get.return_value = mock_response mock_get.return_value = mock_response
with pytest.raises(ValueError): with pytest.raises(ValueError):
elecprice_provider._update_data(force_update=True) provider._update_data(force_update=True)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -154,7 +155,7 @@ def test_update_data_with_incomplete_forecast(mock_get, elecprice_provider):
) )
@patch("requests.get") @patch("requests.get")
def test_request_forecast_status_codes( def test_request_forecast_status_codes(
mock_get, elecprice_provider, sample_akkudoktor_1_json, status_code, exception mock_get, provider, sample_akkudoktor_1_json, status_code, exception
): ):
"""Test handling of various API status codes.""" """Test handling of various API status codes."""
mock_response = Mock() mock_response = Mock()
@@ -166,31 +167,31 @@ def test_request_forecast_status_codes(
mock_get.return_value = mock_response mock_get.return_value = mock_response
if exception: if exception:
with pytest.raises(exception): with pytest.raises(exception):
elecprice_provider._request_forecast() provider._request_forecast()
else: else:
elecprice_provider._request_forecast() provider._request_forecast()
@patch("akkudoktoreos.utils.cacheutil.CacheFileStore") @patch("akkudoktoreos.utils.cacheutil.CacheFileStore")
def test_cache_integration(mock_cache, elecprice_provider): def test_cache_integration(mock_cache, provider):
"""Test caching of 8-day electricity price data.""" """Test caching of 8-day electricity price data."""
mock_cache_instance = mock_cache.return_value mock_cache_instance = mock_cache.return_value
mock_cache_instance.get.return_value = None # Simulate no cache mock_cache_instance.get.return_value = None # Simulate no cache
elecprice_provider._update_data(force_update=True) provider._update_data(force_update=True)
mock_cache_instance.create.assert_called_once() mock_cache_instance.create.assert_called_once()
mock_cache_instance.get.assert_called_once() mock_cache_instance.get.assert_called_once()
def test_key_to_array_resampling(elecprice_provider): def test_key_to_array_resampling(provider):
"""Test resampling of forecast data to NumPy array.""" """Test resampling of forecast data to NumPy array."""
elecprice_provider.update_data(force_update=True) provider.update_data(force_update=True)
array = elecprice_provider.key_to_array( array = provider.key_to_array(
key="elecprice_marketprice_wh", key="elecprice_marketprice_wh",
start_datetime=elecprice_provider.start_datetime, start_datetime=provider.start_datetime,
end_datetime=elecprice_provider.end_datetime, end_datetime=provider.end_datetime,
) )
assert isinstance(array, np.ndarray) assert isinstance(array, np.ndarray)
assert len(array) == elecprice_provider.total_hours assert len(array) == provider.total_hours
# ------------------------------------------------ # ------------------------------------------------
@@ -199,12 +200,12 @@ def test_key_to_array_resampling(elecprice_provider):
@pytest.mark.skip(reason="For development only") @pytest.mark.skip(reason="For development only")
def test_akkudoktor_development_forecast_data(elecprice_provider): def test_akkudoktor_development_forecast_data(provider):
"""Fetch data from real Akkudoktor server.""" """Fetch data from real Akkudoktor server."""
# Preset, as this is usually done by update_data() # Preset, as this is usually done by update_data()
elecprice_provider.start_datetime = to_datetime("2024-10-26 00:00:00") provider.start_datetime = to_datetime("2024-10-26 00:00:00")
akkudoktor_data = elecprice_provider._request_forecast() akkudoktor_data = provider._request_forecast()
with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON, "w") as f_out: with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON, "w") as f_out:
json.dump(akkudoktor_data, f_out, indent=4) json.dump(akkudoktor_data, f_out, indent=4)

View File

@@ -13,12 +13,16 @@ FILE_TESTDATA_ELECPRICEIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.jso
@pytest.fixture @pytest.fixture
def elecprice_provider(sample_import_1_json, config_eos): def provider(sample_import_1_json, config_eos):
"""Fixture to create a ElecPriceProvider instance.""" """Fixture to create a ElecPriceProvider instance."""
settings = { settings = {
"elecprice_provider": "ElecPriceImport", "elecprice": {
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON), "provider": "ElecPriceImport",
"elecpriceimport_json": json.dumps(sample_import_1_json), "provider_settings": {
"import_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
"import_json": json.dumps(sample_import_1_json),
},
}
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
provider = ElecPriceImport() provider = ElecPriceImport()
@@ -39,20 +43,24 @@ def sample_import_1_json():
# ------------------------------------------------ # ------------------------------------------------
def test_singleton_instance(elecprice_provider): def test_singleton_instance(provider):
"""Test that ElecPriceForecast behaves as a singleton.""" """Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceImport() another_instance = ElecPriceImport()
assert elecprice_provider is another_instance assert provider is another_instance
def test_invalid_provider(elecprice_provider, config_eos): def test_invalid_provider(provider, config_eos):
"""Test requesting an unsupported elecprice_provider.""" """Test requesting an unsupported provider."""
settings = { settings = {
"elecprice_provider": "<invalid>", "elecprice": {
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON), "provider": "<invalid>",
"provider_settings": {
"import_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
},
}
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
assert not elecprice_provider.enabled() assert not provider.enabled()
# ------------------------------------------------ # ------------------------------------------------
@@ -73,35 +81,33 @@ def test_invalid_provider(elecprice_provider, config_eos):
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day) ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
], ],
) )
def test_import(elecprice_provider, sample_import_1_json, start_datetime, from_file, config_eos): def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
"""Test fetching forecast from Import.""" """Test fetching forecast from Import."""
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file: if from_file:
config_eos.elecpriceimport_json = None config_eos.elecprice.provider_settings.import_json = None
assert config_eos.elecpriceimport_json is None assert config_eos.elecprice.provider_settings.import_json is None
else: else:
config_eos.elecpriceimport_file_path = None config_eos.elecprice.provider_settings.import_file_path = None
assert config_eos.elecpriceimport_file_path is None assert config_eos.elecprice.provider_settings.import_file_path is None
elecprice_provider.clear() provider.clear()
# Call the method # Call the method
elecprice_provider.update_data() provider.update_data()
# Assert: Verify the result is as expected # Assert: Verify the result is as expected
assert elecprice_provider.start_datetime is not None assert provider.start_datetime is not None
assert elecprice_provider.total_hours is not None assert provider.total_hours is not None
assert compare_datetimes(elecprice_provider.start_datetime, ems_eos.start_datetime).equal assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
values = sample_import_1_json["elecprice_marketprice_wh"] values = sample_import_1_json["elecprice_marketprice_wh"]
value_datetime_mapping = elecprice_provider.import_datetimes( value_datetime_mapping = provider.import_datetimes(ems_eos.start_datetime, len(values))
ems_eos.start_datetime, len(values)
)
for i, mapping in enumerate(value_datetime_mapping): for i, mapping in enumerate(value_datetime_mapping):
assert i < len(elecprice_provider.records) assert i < len(provider.records)
expected_datetime, expected_value_index = mapping expected_datetime, expected_value_index = mapping
expected_value = values[expected_value_index] expected_value = values[expected_value_index]
result_datetime = elecprice_provider.records[i].date_time result_datetime = provider.records[i].date_time
result_value = elecprice_provider.records[i]["elecprice_marketprice_wh"] result_value = provider.records[i]["elecprice_marketprice_wh"]
# print(f"{i}: Expected: {expected_datetime}:{expected_value}") # print(f"{i}: Expected: {expected_datetime}:{expected_value}")
# print(f"{i}: Result: {result_datetime}:{result_value}") # print(f"{i}: Result: {result_datetime}:{result_value}")

View File

@@ -1,4 +1,4 @@
from unittest.mock import Mock from unittest.mock import Mock, patch
import pytest import pytest
@@ -6,22 +6,31 @@ from akkudoktoreos.devices.inverter import Inverter, InverterParameters
@pytest.fixture @pytest.fixture
def mock_battery(): def mock_battery() -> Mock:
mock_battery = Mock() mock_battery = Mock()
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0)) mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0)) mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0))
mock_battery.device_id = "battery1"
return mock_battery return mock_battery
@pytest.fixture @pytest.fixture
def inverter(mock_battery): def inverter(mock_battery, devices_eos) -> Inverter:
devices_eos.add_device(mock_battery)
mock_self_consumption_predictor = Mock() mock_self_consumption_predictor = Mock()
mock_self_consumption_predictor.calculate_self_consumption.return_value = 1.0 mock_self_consumption_predictor.calculate_self_consumption.return_value = 1.0
return Inverter( with patch(
mock_self_consumption_predictor, "akkudoktoreos.devices.inverter.get_eos_load_interpolator",
InverterParameters(max_power_wh=500.0), return_value=mock_self_consumption_predictor,
battery=mock_battery, ):
iv = Inverter(
InverterParameters(
device_id="iv1", max_power_wh=500.0, battery_id=mock_battery.device_id
),
) )
devices_eos.add_device(iv)
devices_eos.post_setup()
return iv
def test_process_energy_excess_generation(inverter, mock_battery): def test_process_energy_excess_generation(inverter, mock_battery):

View File

@@ -14,12 +14,16 @@ from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_
@pytest.fixture @pytest.fixture
def load_provider(config_eos): def provider(config_eos):
"""Fixture to initialise the LoadAkkudoktor instance.""" """Fixture to initialise the LoadAkkudoktor instance."""
settings = { settings = {
"load_provider": "LoadAkkudoktor", "load": {
"provider": "LoadAkkudoktor",
"provider_settings": {
"load_name": "Akkudoktor Profile", "load_name": "Akkudoktor Profile",
"loadakkudoktor_year_energy": "1000", "loadakkudoktor_year_energy": "1000",
},
}
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
return LoadAkkudoktor() return LoadAkkudoktor()
@@ -37,8 +41,8 @@ def measurement_eos():
measurement.records.append( measurement.records.append(
MeasurementDataRecord( MeasurementDataRecord(
date_time=dt, date_time=dt,
measurement_load0_mr=load0_mr, load0_mr=load0_mr,
measurement_load1_mr=load1_mr, load1_mr=load1_mr,
) )
) )
dt += interval dt += interval
@@ -72,13 +76,13 @@ def test_loadakkudoktor_settings_validator():
assert settings.loadakkudoktor_year_energy == 1234.56 assert settings.loadakkudoktor_year_energy == 1234.56
def test_loadakkudoktor_provider_id(load_provider): def test_loadakkudoktor_provider_id(provider):
"""Test the `provider_id` class method.""" """Test the `provider_id` class method."""
assert load_provider.provider_id() == "LoadAkkudoktor" assert provider.provider_id() == "LoadAkkudoktor"
@patch("akkudoktoreos.prediction.loadakkudoktor.np.load") @patch("akkudoktoreos.prediction.loadakkudoktor.np.load")
def test_load_data_from_mock(mock_np_load, mock_load_profiles_file, load_provider): def test_load_data_from_mock(mock_np_load, mock_load_profiles_file, provider):
"""Test the `load_data` method.""" """Test the `load_data` method."""
# Mock numpy load to return data similar to what would be in the file # Mock numpy load to return data similar to what would be in the file
mock_np_load.return_value = { mock_np_load.return_value = {
@@ -87,19 +91,19 @@ def test_load_data_from_mock(mock_np_load, mock_load_profiles_file, load_provide
} }
# Test data loading # Test data loading
data_year_energy = load_provider.load_data() data_year_energy = provider.load_data()
assert data_year_energy is not None assert data_year_energy is not None
assert data_year_energy.shape == (365, 2, 24) assert data_year_energy.shape == (365, 2, 24)
def test_load_data_from_file(load_provider): def test_load_data_from_file(provider):
"""Test `load_data` loads data from the profiles file.""" """Test `load_data` loads data from the profiles file."""
data_year_energy = load_provider.load_data() data_year_energy = provider.load_data()
assert data_year_energy is not None assert data_year_energy is not None
@patch("akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktor.load_data") @patch("akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktor.load_data")
def test_update_data(mock_load_data, load_provider): def test_update_data(mock_load_data, provider):
"""Test the `_update` method.""" """Test the `_update` method."""
mock_load_data.return_value = np.random.rand(365, 2, 24) mock_load_data.return_value = np.random.rand(365, 2, 24)
@@ -108,27 +112,27 @@ def test_update_data(mock_load_data, load_provider):
ems_eos.set_start_datetime(pendulum.datetime(2024, 1, 1)) ems_eos.set_start_datetime(pendulum.datetime(2024, 1, 1))
# Assure there are no prediction records # Assure there are no prediction records
load_provider.clear() provider.clear()
assert len(load_provider) == 0 assert len(provider) == 0
# Execute the method # Execute the method
load_provider._update_data() provider._update_data()
# Validate that update_value is called # Validate that update_value is called
assert len(load_provider) > 0 assert len(provider) > 0
def test_calculate_adjustment(load_provider, measurement_eos): def test_calculate_adjustment(provider, measurement_eos):
"""Test `_calculate_adjustment` for various scenarios.""" """Test `_calculate_adjustment` for various scenarios."""
data_year_energy = np.random.rand(365, 2, 24) data_year_energy = np.random.rand(365, 2, 24)
# Call the method and validate results # Call the method and validate results
weekday_adjust, weekend_adjust = load_provider._calculate_adjustment(data_year_energy) weekday_adjust, weekend_adjust = provider._calculate_adjustment(data_year_energy)
assert weekday_adjust.shape == (24,) assert weekday_adjust.shape == (24,)
assert weekend_adjust.shape == (24,) assert weekend_adjust.shape == (24,)
data_year_energy = np.zeros((365, 2, 24)) data_year_energy = np.zeros((365, 2, 24))
weekday_adjust, weekend_adjust = load_provider._calculate_adjustment(data_year_energy) weekday_adjust, weekend_adjust = provider._calculate_adjustment(data_year_energy)
assert weekday_adjust.shape == (24,) assert weekday_adjust.shape == (24,)
expected = np.array( expected = np.array(
@@ -193,7 +197,7 @@ def test_calculate_adjustment(load_provider, measurement_eos):
np.testing.assert_array_equal(weekend_adjust, expected) np.testing.assert_array_equal(weekend_adjust, expected)
def test_load_provider_adjustments_with_mock_data(load_provider): def test_provider_adjustments_with_mock_data(provider):
"""Test full integration of adjustments with mock data.""" """Test full integration of adjustments with mock data."""
with patch( with patch(
"akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktor._calculate_adjustment" "akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktor._calculate_adjustment"
@@ -201,5 +205,5 @@ def test_load_provider_adjustments_with_mock_data(load_provider):
mock_adjust.return_value = (np.zeros(24), np.zeros(24)) mock_adjust.return_value = (np.zeros(24), np.zeros(24))
# Test execution # Test execution
load_provider._update_data() provider._update_data()
assert mock_adjust.called assert mock_adjust.called

View File

@@ -3,7 +3,11 @@ import pytest
from pendulum import datetime, duration from pendulum import datetime, duration
from akkudoktoreos.config.config import SettingsEOS from akkudoktoreos.config.config import SettingsEOS
from akkudoktoreos.measurement.measurement import MeasurementDataRecord, get_measurement from akkudoktoreos.measurement.measurement import (
MeasurementCommonSettings,
MeasurementDataRecord,
get_measurement,
)
@pytest.fixture @pytest.fixture
@@ -13,33 +17,33 @@ def measurement_eos():
measurement.records = [ measurement.records = [
MeasurementDataRecord( MeasurementDataRecord(
date_time=datetime(2023, 1, 1, hour=0), date_time=datetime(2023, 1, 1, hour=0),
measurement_load0_mr=100, load0_mr=100,
measurement_load1_mr=200, load1_mr=200,
), ),
MeasurementDataRecord( MeasurementDataRecord(
date_time=datetime(2023, 1, 1, hour=1), date_time=datetime(2023, 1, 1, hour=1),
measurement_load0_mr=150, load0_mr=150,
measurement_load1_mr=250, load1_mr=250,
), ),
MeasurementDataRecord( MeasurementDataRecord(
date_time=datetime(2023, 1, 1, hour=2), date_time=datetime(2023, 1, 1, hour=2),
measurement_load0_mr=200, load0_mr=200,
measurement_load1_mr=300, load1_mr=300,
), ),
MeasurementDataRecord( MeasurementDataRecord(
date_time=datetime(2023, 1, 1, hour=3), date_time=datetime(2023, 1, 1, hour=3),
measurement_load0_mr=250, load0_mr=250,
measurement_load1_mr=350, load1_mr=350,
), ),
MeasurementDataRecord( MeasurementDataRecord(
date_time=datetime(2023, 1, 1, hour=4), date_time=datetime(2023, 1, 1, hour=4),
measurement_load0_mr=300, load0_mr=300,
measurement_load1_mr=400, load1_mr=400,
), ),
MeasurementDataRecord( MeasurementDataRecord(
date_time=datetime(2023, 1, 1, hour=5), date_time=datetime(2023, 1, 1, hour=5),
measurement_load0_mr=350, load0_mr=350,
measurement_load1_mr=450, load1_mr=450,
), ),
] ]
return measurement return measurement
@@ -75,7 +79,7 @@ def test_interval_count_invalid_non_positive_interval(measurement_eos):
def test_energy_from_meter_readings_valid_input(measurement_eos): def test_energy_from_meter_readings_valid_input(measurement_eos):
"""Test _energy_from_meter_readings with valid inputs and proper alignment of load data.""" """Test _energy_from_meter_readings with valid inputs and proper alignment of load data."""
key = "measurement_load0_mr" key = "load0_mr"
start_datetime = datetime(2023, 1, 1, 0) start_datetime = datetime(2023, 1, 1, 0)
end_datetime = datetime(2023, 1, 1, 5) end_datetime = datetime(2023, 1, 1, 5)
interval = duration(hours=1) interval = duration(hours=1)
@@ -90,7 +94,7 @@ def test_energy_from_meter_readings_valid_input(measurement_eos):
def test_energy_from_meter_readings_empty_array(measurement_eos): def test_energy_from_meter_readings_empty_array(measurement_eos):
"""Test _energy_from_meter_readings with no data (empty array).""" """Test _energy_from_meter_readings with no data (empty array)."""
key = "measurement_load0_mr" key = "load0_mr"
start_datetime = datetime(2023, 1, 1, 0) start_datetime = datetime(2023, 1, 1, 0)
end_datetime = datetime(2023, 1, 1, 5) end_datetime = datetime(2023, 1, 1, 5)
interval = duration(hours=1) interval = duration(hours=1)
@@ -112,7 +116,7 @@ def test_energy_from_meter_readings_empty_array(measurement_eos):
def test_energy_from_meter_readings_misaligned_array(measurement_eos): def test_energy_from_meter_readings_misaligned_array(measurement_eos):
"""Test _energy_from_meter_readings with misaligned array size.""" """Test _energy_from_meter_readings with misaligned array size."""
key = "measurement_load1_mr" key = "load1_mr"
start_datetime = measurement_eos.min_datetime start_datetime = measurement_eos.min_datetime
end_datetime = measurement_eos.max_datetime end_datetime = measurement_eos.max_datetime
interval = duration(hours=1) interval = duration(hours=1)
@@ -130,7 +134,7 @@ def test_energy_from_meter_readings_misaligned_array(measurement_eos):
def test_energy_from_meter_readings_partial_data(measurement_eos, caplog): def test_energy_from_meter_readings_partial_data(measurement_eos, caplog):
"""Test _energy_from_meter_readings with partial data (misaligned but empty array).""" """Test _energy_from_meter_readings with partial data (misaligned but empty array)."""
key = "measurement_load2_mr" key = "load2_mr"
start_datetime = datetime(2023, 1, 1, 0) start_datetime = datetime(2023, 1, 1, 0)
end_datetime = datetime(2023, 1, 1, 5) end_datetime = datetime(2023, 1, 1, 5)
interval = duration(hours=1) interval = duration(hours=1)
@@ -149,7 +153,7 @@ def test_energy_from_meter_readings_partial_data(measurement_eos, caplog):
def test_energy_from_meter_readings_negative_interval(measurement_eos): def test_energy_from_meter_readings_negative_interval(measurement_eos):
"""Test _energy_from_meter_readings with a negative interval.""" """Test _energy_from_meter_readings with a negative interval."""
key = "measurement_load3_mr" key = "load3_mr"
start_datetime = datetime(2023, 1, 1, 0) start_datetime = datetime(2023, 1, 1, 0)
end_datetime = datetime(2023, 1, 1, 5) end_datetime = datetime(2023, 1, 1, 5)
interval = duration(hours=-1) interval = duration(hours=-1)
@@ -186,21 +190,25 @@ def test_load_total_no_data(measurement_eos):
def test_name_to_key(measurement_eos): def test_name_to_key(measurement_eos):
"""Test name_to_key functionality.""" """Test name_to_key functionality."""
settings = SettingsEOS( settings = SettingsEOS(
measurement_load0_name="Household", measurement=MeasurementCommonSettings(
measurement_load1_name="Heat Pump", load0_name="Household",
load1_name="Heat Pump",
)
) )
measurement_eos.config.merge_settings(settings) measurement_eos.config.merge_settings(settings)
assert measurement_eos.name_to_key("Household", "measurement_load") == "measurement_load0_mr" assert measurement_eos.name_to_key("Household", "load") == "load0_mr"
assert measurement_eos.name_to_key("Heat Pump", "measurement_load") == "measurement_load1_mr" assert measurement_eos.name_to_key("Heat Pump", "load") == "load1_mr"
assert measurement_eos.name_to_key("Unknown", "measurement_load") is None assert measurement_eos.name_to_key("Unknown", "load") is None
def test_name_to_key_invalid_topic(measurement_eos): def test_name_to_key_invalid_topic(measurement_eos):
"""Test name_to_key with an invalid topic.""" """Test name_to_key with an invalid topic."""
settings = SettingsEOS( settings = SettingsEOS(
measurement_load0_name="Household", MeasurementCommonSettings(
measurement_load1_name="Heat Pump", load0_name="Household",
load1_name="Heat Pump",
)
) )
measurement_eos.config.merge_settings(settings) measurement_eos.config.merge_settings(settings)

View File

@@ -17,25 +17,6 @@ from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport from akkudoktoreos.prediction.weatherimport import WeatherImport
@pytest.fixture
def sample_settings(config_eos):
"""Fixture that adds settings data to the global config."""
settings = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"weather_provider": None,
"pvforecast_provider": None,
"load_provider": None,
"elecprice_provider": None,
}
# Merge settings to config
config_eos.merge_settings_from_dict(settings)
return config_eos
@pytest.fixture @pytest.fixture
def prediction(): def prediction():
"""All EOS predictions.""" """All EOS predictions."""
@@ -58,83 +39,26 @@ def forecast_providers():
] ]
@pytest.mark.parametrize(
"prediction_hours, prediction_historic_hours, latitude, longitude, expected_timezone",
[
(48, 24, 40.7128, -74.0060, "America/New_York"), # Valid latitude/longitude
(0, 0, None, None, None), # No location
(100, 50, 51.5074, -0.1278, "Europe/London"), # Another valid location
],
)
def test_prediction_common_settings_valid(
prediction_hours, prediction_historic_hours, latitude, longitude, expected_timezone
):
"""Test valid settings for PredictionCommonSettings."""
settings = PredictionCommonSettings(
prediction_hours=prediction_hours,
prediction_historic_hours=prediction_historic_hours,
latitude=latitude,
longitude=longitude,
)
assert settings.prediction_hours == prediction_hours
assert settings.prediction_historic_hours == prediction_historic_hours
assert settings.latitude == latitude
assert settings.longitude == longitude
assert settings.timezone == expected_timezone
@pytest.mark.parametrize( @pytest.mark.parametrize(
"field_name, invalid_value, expected_error", "field_name, invalid_value, expected_error",
[ [
("prediction_hours", -1, "Input should be greater than or equal to 0"), ("hours", -1, "Input should be greater than or equal to 0"),
("prediction_historic_hours", -5, "Input should be greater than or equal to 0"), ("historic_hours", -5, "Input should be greater than or equal to 0"),
("latitude", -91.0, "Input should be greater than or equal to -90"),
("latitude", 91.0, "Input should be less than or equal to 90"),
("longitude", -181.0, "Input should be greater than or equal to -180"),
("longitude", 181.0, "Input should be less than or equal to 180"),
], ],
) )
def test_prediction_common_settings_invalid(field_name, invalid_value, expected_error): def test_prediction_common_settings_invalid(field_name, invalid_value, expected_error, config_eos):
"""Test invalid settings for PredictionCommonSettings.""" """Test invalid settings for PredictionCommonSettings."""
valid_data = { valid_data = {
"prediction_hours": 48, "hours": 48,
"prediction_historic_hours": 24, "historic_hours": 24,
"latitude": 40.7128,
"longitude": -74.0060,
} }
assert PredictionCommonSettings(**valid_data) is not None
valid_data[field_name] = invalid_value valid_data[field_name] = invalid_value
with pytest.raises(ValidationError, match=expected_error): with pytest.raises(ValidationError, match=expected_error):
PredictionCommonSettings(**valid_data) PredictionCommonSettings(**valid_data)
def test_prediction_common_settings_no_location():
"""Test that timezone is None when latitude and longitude are not provided."""
settings = PredictionCommonSettings(
prediction_hours=48, prediction_historic_hours=24, latitude=None, longitude=None
)
assert settings.timezone is None
def test_prediction_common_settings_with_location():
"""Test that timezone is correctly computed when latitude and longitude are provided."""
settings = PredictionCommonSettings(
prediction_hours=48, prediction_historic_hours=24, latitude=34.0522, longitude=-118.2437
)
assert settings.timezone == "America/Los_Angeles"
def test_prediction_common_settings_timezone_none_when_coordinates_missing():
"""Test that timezone is None when latitude or longitude is missing."""
config_no_latitude = PredictionCommonSettings(longitude=-74.0060)
config_no_longitude = PredictionCommonSettings(latitude=40.7128)
config_no_coords = PredictionCommonSettings()
assert config_no_latitude.timezone is None
assert config_no_longitude.timezone is None
assert config_no_coords.timezone is None
def test_initialization(prediction, forecast_providers): def test_initialization(prediction, forecast_providers):
"""Test that Prediction is initialized with the correct providers in sequence.""" """Test that Prediction is initialized with the correct providers in sequence."""
assert isinstance(prediction, Prediction) assert isinstance(prediction, Prediction)

View File

@@ -88,31 +88,31 @@ class TestPredictionBase:
@pytest.fixture @pytest.fixture
def base(self, monkeypatch): def base(self, monkeypatch):
# Provide default values for configuration # Provide default values for configuration
monkeypatch.setenv("latitude", "50.0") monkeypatch.setenv("EOS_PREDICTION__HOURS", "10")
monkeypatch.setenv("longitude", "10.0")
derived = DerivedBase() derived = DerivedBase()
derived.config.update() derived.config.reset_settings()
assert derived.config.prediction.hours == 10
return derived return derived
def test_config_value_from_env_variable(self, base, monkeypatch): def test_config_value_from_env_variable(self, base, monkeypatch):
# From Prediction Config # From Prediction Config
monkeypatch.setenv("latitude", "2.5") monkeypatch.setenv("EOS_PREDICTION__HOURS", "2")
base.config.update() base.config.reset_settings()
assert base.config.latitude == 2.5 assert base.config.prediction.hours == 2
def test_config_value_from_field_default(self, base, monkeypatch): def test_config_value_from_field_default(self, base, monkeypatch):
assert base.config.model_fields["prediction_hours"].default == 48 assert base.config.prediction.model_fields["historic_hours"].default == 48
assert base.config.prediction_hours == 48 assert base.config.prediction.historic_hours == 48
monkeypatch.setenv("prediction_hours", "128") monkeypatch.setenv("EOS_PREDICTION__HISTORIC_HOURS", "128")
base.config.update() base.config.reset_settings()
assert base.config.prediction_hours == 128 assert base.config.prediction.historic_hours == 128
monkeypatch.delenv("prediction_hours") monkeypatch.delenv("EOS_PREDICTION__HISTORIC_HOURS")
base.config.update() base.config.reset_settings()
assert base.config.prediction_hours == 48 assert base.config.prediction.historic_hours == 48
def test_get_config_value_key_error(self, base): def test_get_config_value_key_error(self, base):
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
base.config.non_existent_key base.config.prediction.non_existent_key
# TestPredictionRecord fully covered by TestDataRecord # TestPredictionRecord fully covered by TestDataRecord
@@ -159,14 +159,14 @@ class TestPredictionProvider:
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated.""" """Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(sample_start_datetime) ems_eos.set_start_datetime(sample_start_datetime)
provider.config.prediction_hours = 24 # 24 hours into the future provider.config.prediction.hours = 24 # 24 hours into the future
provider.config.prediction_historic_hours = 48 # 48 hours into the past provider.config.prediction.historic_hours = 48 # 48 hours into the past
expected_end_datetime = sample_start_datetime + to_duration( expected_end_datetime = sample_start_datetime + to_duration(
provider.config.prediction_hours * 3600 provider.config.prediction.hours * 3600
) )
expected_keep_datetime = sample_start_datetime - to_duration( expected_keep_datetime = sample_start_datetime - to_duration(
provider.config.prediction_historic_hours * 3600 provider.config.prediction.historic_hours * 3600
) )
assert ( assert (
@@ -183,31 +183,26 @@ class TestPredictionProvider:
# EOS config supersedes # EOS config supersedes
ems_eos = get_ems() ems_eos = get_ems()
# The following values are currently not set in EOS config, we can override # The following values are currently not set in EOS config, we can override
monkeypatch.setenv("prediction_historic_hours", "2") monkeypatch.setenv("EOS_PREDICTION__HISTORIC_HOURS", "2")
assert os.getenv("prediction_historic_hours") == "2" assert os.getenv("EOS_PREDICTION__HISTORIC_HOURS") == "2"
monkeypatch.setenv("latitude", "37.7749") provider.config.reset_settings()
assert os.getenv("latitude") == "37.7749"
monkeypatch.setenv("longitude", "-122.4194")
assert os.getenv("longitude") == "-122.4194"
ems_eos.set_start_datetime(sample_start_datetime) ems_eos.set_start_datetime(sample_start_datetime)
provider.update_data() provider.update_data()
assert provider.config.prediction_hours == config_eos.prediction_hours assert provider.config.prediction.hours == config_eos.prediction.hours
assert provider.config.prediction_historic_hours == 2 assert provider.config.prediction.historic_hours == 2
assert provider.config.latitude == 37.7749
assert provider.config.longitude == -122.4194
assert provider.start_datetime == sample_start_datetime assert provider.start_datetime == sample_start_datetime
assert provider.end_datetime == sample_start_datetime + to_duration( assert provider.end_datetime == sample_start_datetime + to_duration(
f"{provider.config.prediction_hours} hours" f"{provider.config.prediction.hours} hours"
) )
assert provider.keep_datetime == sample_start_datetime - to_duration("2 hours") assert provider.keep_datetime == sample_start_datetime - to_duration("2 hours")
def test_update_method_force_enable(self, provider, monkeypatch): def test_update_method_force_enable(self, provider, monkeypatch):
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False.""" """Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
# Preset values that are needed by update # Preset values that are needed by update
monkeypatch.setenv("latitude", "37.7749") monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "37.7749")
monkeypatch.setenv("longitude", "-122.4194") monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "-122.4194")
# Override enabled to return False for this test # Override enabled to return False for this test
DerivedPredictionProvider.provider_enabled = False DerivedPredictionProvider.provider_enabled = False
@@ -288,7 +283,9 @@ class TestPredictionContainer:
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = { settings = {
"prediction_hours": hours, "prediction": {
"hours": hours,
}
} }
container.config.merge_settings_from_dict(settings) container.config.merge_settings_from_dict(settings)
expected = to_datetime(end, in_timezone="Europe/Berlin") expected = to_datetime(end, in_timezone="Europe/Berlin")
@@ -316,14 +313,16 @@ class TestPredictionContainer:
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = { settings = {
"prediction_historic_hours": historic_hours, "prediction": {
"historic_hours": historic_hours,
}
} }
container.config.merge_settings_from_dict(settings) container.config.merge_settings_from_dict(settings)
expected = to_datetime(expected_keep, in_timezone="Europe/Berlin") expected = to_datetime(expected_keep, in_timezone="Europe/Berlin")
assert compare_datetimes(container.keep_datetime, expected).equal assert compare_datetimes(container.keep_datetime, expected).equal
@pytest.mark.parametrize( @pytest.mark.parametrize(
"start, prediction_hours, expected_hours", "start, hours, expected_hours",
[ [
("2024-11-10 00:00:00", 24, 24), # No DST in Germany ("2024-11-10 00:00:00", 24, 24), # No DST in Germany
("2024-08-10 00:00:00", 24, 24), # DST in Germany ("2024-08-10 00:00:00", 24, 24), # DST in Germany
@@ -331,12 +330,14 @@ class TestPredictionContainer:
("2024-10-27 00:00:00", 24, 25), # DST change in Germany (25 hours/ day) ("2024-10-27 00:00:00", 24, 25), # DST change in Germany (25 hours/ day)
], ],
) )
def test_total_hours(self, container, start, prediction_hours, expected_hours): def test_total_hours(self, container, start, hours, expected_hours):
"""Test the `total_hours` property.""" """Test the `total_hours` property."""
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = { settings = {
"prediction_hours": prediction_hours, "prediction": {
"hours": hours,
}
} }
container.config.merge_settings_from_dict(settings) container.config.merge_settings_from_dict(settings)
assert container.total_hours == expected_hours assert container.total_hours == expected_hours
@@ -355,7 +356,9 @@ class TestPredictionContainer:
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = { settings = {
"prediction_historic_hours": historic_hours, "prediction": {
"historic_hours": historic_hours,
}
} }
container.config.merge_settings_from_dict(settings) container.config.merge_settings_from_dict(settings)
assert container.keep_hours == expected_hours assert container.keep_hours == expected_hours

View File

@@ -1,82 +1,75 @@
import pytest import pytest
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.pvforecast import (
PVForecastCommonSettings,
PVForecastPlaneSetting,
)
@pytest.fixture @pytest.fixture
def settings(): def settings():
"""Fixture that creates an empty PVForecastSettings.""" """Fixture that creates an empty PVForecastSettings."""
settings = PVForecastCommonSettings() settings = PVForecastCommonSettings()
assert settings.planes is None
# Check default values for plane 0
assert settings.pvforecast0_surface_tilt is None
assert settings.pvforecast0_surface_azimuth is None
assert settings.pvforecast0_pvtechchoice == "crystSi"
assert settings.pvforecast0_mountingplace == "free"
assert settings.pvforecast0_trackingtype is None
assert settings.pvforecast0_optimal_surface_tilt is False
assert settings.pvforecast0_optimalangles is False
# Check default values for plane 1
assert settings.pvforecast1_surface_azimuth is None
assert settings.pvforecast1_pvtechchoice == "crystSi"
assert settings.pvforecast1_mountingplace == "free"
assert settings.pvforecast1_trackingtype is None
assert settings.pvforecast1_optimal_surface_tilt is False
assert settings.pvforecast1_optimalangles is False
expected_planes: list[str] = []
assert settings.pvforecast_planes == expected_planes
return settings return settings
def test_active_planes_detection(settings):
"""Test that active planes are correctly detected based on tilt and azimuth."""
settings.pvforecast1_surface_tilt = 10.0
settings.pvforecast1_surface_azimuth = 10.0
settings.pvforecast2_surface_tilt = 20.0
settings.pvforecast2_surface_azimuth = 20.0
expected_planes = ["pvforecast1", "pvforecast2"]
assert settings.pvforecast_planes == expected_planes
def test_planes_peakpower_computation(settings): def test_planes_peakpower_computation(settings):
"""Test computation of peak power for active planes.""" """Test computation of peak power for active planes."""
settings.pvforecast1_surface_tilt = 10.0 settings.planes = [
settings.pvforecast1_surface_azimuth = 10.0 PVForecastPlaneSetting(
settings.pvforecast1_peakpower = 5.0 surface_tilt=10.0,
settings.pvforecast2_surface_tilt = 20.0 surface_azimuth=10.0,
settings.pvforecast2_surface_azimuth = 20.0 peakpower=5.0,
settings.pvforecast2_peakpower = 3.5 ),
settings.pvforecast3_surface_tilt = 30.0 PVForecastPlaneSetting(
settings.pvforecast3_surface_azimuth = 30.0 surface_tilt=20.0,
settings.pvforecast3_modules_per_string = 20 # Should use default 5000W surface_azimuth=20.0,
peakpower=3.5,
),
PVForecastPlaneSetting(
surface_tilt=30.0,
surface_azimuth=30.0,
modules_per_string=20, # Should use default 5000W
),
]
expected_peakpower = [5.0, 3.5, 5000.0] expected_peakpower = [5.0, 3.5, 5000.0]
assert settings.pvforecast_planes_peakpower == expected_peakpower assert settings.planes_peakpower == expected_peakpower
def test_planes_azimuth_computation(settings): def test_planes_azimuth_computation(settings):
"""Test computation of azimuth values for active planes.""" """Test computation of azimuth values for active planes."""
settings.pvforecast1_surface_tilt = 10.0 settings.planes = [
settings.pvforecast1_surface_azimuth = 10.0 PVForecastPlaneSetting(
settings.pvforecast2_surface_tilt = 20.0 surface_tilt=10.0,
settings.pvforecast2_surface_azimuth = 20.0 surface_azimuth=10.0,
),
PVForecastPlaneSetting(
surface_tilt=20.0,
surface_azimuth=20.0,
),
]
expected_azimuths = [10.0, 20.0] expected_azimuths = [10.0, 20.0]
assert settings.pvforecast_planes_azimuth == expected_azimuths assert settings.planes_azimuth == expected_azimuths
def test_planes_tilt_computation(settings): def test_planes_tilt_computation(settings):
"""Test computation of tilt values for active planes.""" """Test computation of tilt values for active planes."""
settings.pvforecast1_surface_tilt = 10.0 settings.planes = [
settings.pvforecast1_surface_azimuth = 10.0 PVForecastPlaneSetting(
settings.pvforecast2_surface_tilt = 20.0 surface_tilt=10.0,
settings.pvforecast2_surface_azimuth = 20.0 surface_azimuth=10.0,
),
PVForecastPlaneSetting(
surface_tilt=20.0,
surface_azimuth=20.0,
),
]
expected_tilts = [10.0, 20.0] expected_tilts = [10.0, 20.0]
assert settings.pvforecast_planes_tilt == expected_tilts assert settings.planes_tilt == expected_tilts
def test_planes_userhorizon_computation(settings): def test_planes_userhorizon_computation(settings):
@@ -84,116 +77,84 @@ def test_planes_userhorizon_computation(settings):
horizon1 = [10.0, 20.0, 30.0] horizon1 = [10.0, 20.0, 30.0]
horizon2 = [5.0, 15.0, 25.0] horizon2 = [5.0, 15.0, 25.0]
settings.pvforecast1_surface_tilt = 10.0 settings.planes = [
settings.pvforecast1_surface_azimuth = 10.0 PVForecastPlaneSetting(
settings.pvforecast1_userhorizon = horizon1 surface_tilt=10.0,
settings.pvforecast2_surface_tilt = 20.0 surface_azimuth=10.0,
settings.pvforecast2_surface_azimuth = 20.0 userhorizon=horizon1,
settings.pvforecast2_userhorizon = horizon2 ),
PVForecastPlaneSetting(
surface_tilt=20.0,
surface_azimuth=20.0,
userhorizon=horizon2,
),
]
expected_horizons = [horizon1, horizon2] expected_horizons = [horizon1, horizon2]
assert settings.pvforecast_planes_userhorizon == expected_horizons assert settings.planes_userhorizon == expected_horizons
def test_planes_inverter_paco_computation(settings): def test_planes_inverter_paco_computation(settings):
"""Test computation of inverter power rating for active planes.""" """Test computation of inverter power rating for active planes."""
settings.pvforecast1_surface_tilt = 10.0 settings.planes = [
settings.pvforecast1_surface_azimuth = 10.0 PVForecastPlaneSetting(
settings.pvforecast1_inverter_paco = 6000 surface_tilt=10.0,
settings.pvforecast2_surface_tilt = 20.0 surface_azimuth=10.0,
settings.pvforecast2_surface_azimuth = 20.0 inverter_paco=6000,
settings.pvforecast2_inverter_paco = 4000 ),
PVForecastPlaneSetting(
surface_tilt=20.0,
surface_azimuth=20.0,
inverter_paco=4000,
),
]
expected_paco = [6000, 4000] expected_paco = [6000, 4000]
assert settings.pvforecast_planes_inverter_paco == expected_paco assert settings.planes_inverter_paco == expected_paco
def test_non_sequential_plane_numbers(settings):
"""Test that non-sequential plane numbers are handled correctly."""
settings.pvforecast1_surface_tilt = 10.0
settings.pvforecast1_surface_azimuth = 10.0
settings.pvforecast1_peakpower = 5.0
settings.pvforecast3_surface_tilt = 30.0
settings.pvforecast3_surface_azimuth = 30.0
settings.pvforecast3_peakpower = 3.5
settings.pvforecast5_surface_tilt = 50.0
settings.pvforecast5_surface_azimuth = 50.0
settings.pvforecast5_peakpower = 2.0
expected_planes = ["pvforecast1", "pvforecast3", "pvforecast5"]
assert settings.pvforecast_planes == expected_planes
assert settings.pvforecast_planes_peakpower == [5.0, 3.5, 2.0]
def test_mixed_plane_configuration(settings): def test_mixed_plane_configuration(settings):
"""Test mixed configuration with some planes having peak power and others having modules.""" """Test mixed configuration with some planes having peak power and others having modules."""
settings.pvforecast1_surface_tilt = 10.0 settings.planes = [
settings.pvforecast1_surface_azimuth = 10.0 PVForecastPlaneSetting(
settings.pvforecast1_peakpower = 5.0 surface_tilt=10.0,
settings.pvforecast2_surface_tilt = 20.0 surface_azimuth=10.0,
settings.pvforecast2_surface_azimuth = 20.0 peakpower=5.0,
settings.pvforecast2_modules_per_string = 20 ),
settings.pvforecast2_strings_per_inverter = 2 PVForecastPlaneSetting(
settings.pvforecast4_surface_tilt = 40.0 surface_tilt=20.0,
settings.pvforecast4_surface_azimuth = 40.0 surface_azimuth=20.0,
settings.pvforecast4_peakpower = 3.0 modules_per_string=20,
strings_per_inverter=2,
),
PVForecastPlaneSetting(
surface_tilt=40.0,
surface_azimuth=40.0,
peakpower=3.0,
),
]
expected_planes = ["pvforecast1", "pvforecast2", "pvforecast4"]
assert settings.pvforecast_planes == expected_planes
# First plane uses specified peak power, second uses default, third uses specified # First plane uses specified peak power, second uses default, third uses specified
assert settings.pvforecast_planes_peakpower == [5.0, 5000.0, 3.0] assert settings.planes_peakpower == [5.0, 5000.0, 3.0]
def test_max_planes_limit(settings): def test_max_planes_limit(settings):
"""Test that the maximum number of planes is enforced.""" """Test that the maximum number of planes is enforced."""
assert settings.pvforecast_max_planes == 6 assert settings.max_planes == 6
# Create settings with more planes than allowed (should only recognize up to max) # Create settings with more planes than allowed (should only recognize up to max)
plane_settings = {} plane_settings = [{"peakpower": 5.0} for _ in range(8)]
for i in range(1, 8): # Try to set up 7 planes, skipping plane 0
plane_settings[f"pvforecast{i}_peakpower"] = 5.0
settings = PVForecastCommonSettings(**plane_settings) with pytest.raises(ValueError):
assert len(settings.pvforecast_planes) <= settings.pvforecast_max_planes PVForecastCommonSettings(planes=plane_settings)
def test_optional_parameters_non_zero_plane(settings): def test_invalid_plane_settings():
"""Test that optional parameters can be None for non-zero planes.""" """Test that optional parameters can be None for non-zero planes."""
settings.pvforecast1_peakpower = 5.0 with pytest.raises(ValueError):
settings.pvforecast1_albedo = None PVForecastPlaneSetting(
settings.pvforecast1_module_model = None peakpower=5.0,
settings.pvforecast1_userhorizon = None albedo=None,
module_model=None,
assert settings.pvforecast1_albedo is None userhorizon=None,
assert settings.pvforecast1_module_model is None )
assert settings.pvforecast1_userhorizon is None
def test_tracking_type_values_non_zero_plane(settings):
"""Test valid tracking type values for non-zero planes."""
valid_types = [0, 1, 2, 3, 4, 5]
for tracking_type in valid_types:
settings.pvforecast1_peakpower = 5.0
settings.pvforecast1_trackingtype = tracking_type
assert settings.pvforecast1_trackingtype == tracking_type
def test_pv_technology_values_non_zero_plane(settings):
"""Test valid PV technology values for non-zero planes."""
valid_technologies = ["crystSi", "CIS", "CdTe", "Unknown"]
for tech in valid_technologies:
settings.pvforecast2_peakpower = 5.0
settings.pvforecast2_pvtechchoice = tech
assert settings.pvforecast2_pvtechchoice == tech
def test_mounting_place_values_non_zero_plane(settings):
"""Test valid mounting place values for non-zero planes."""
valid_mounting = ["free", "building"]
for mounting in valid_mounting:
settings.pvforecast3_peakpower = 5.0
settings.pvforecast3_mountingplace = mounting
assert settings.pvforecast3_mountingplace == mounting

View File

@@ -25,36 +25,52 @@ FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1
def sample_settings(config_eos): def sample_settings(config_eos):
"""Fixture that adds settings data to the global config.""" """Fixture that adds settings data to the global config."""
settings = { settings = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24,
"latitude": 52.52, "latitude": 52.52,
"longitude": 13.405, "longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor", },
"pvforecast0_peakpower": 5.0, "prediction": {
"pvforecast0_surface_azimuth": -10, "hours": 48,
"pvforecast0_surface_tilt": 7, "historic_hours": 24,
"pvforecast0_userhorizon": [20, 27, 22, 20], },
"pvforecast0_inverter_paco": 10000, "pvforecast": {
"pvforecast1_peakpower": 4.8, "provider": "PVForecastAkkudoktor",
"pvforecast1_surface_azimuth": -90, "planes": [
"pvforecast1_surface_tilt": 7, {
"pvforecast1_userhorizon": [30, 30, 30, 50], "peakpower": 5.0,
"pvforecast1_inverter_paco": 10000, "surface_azimuth": -10,
"pvforecast2_peakpower": 1.4, "surface_tilt": 7,
"pvforecast2_surface_azimuth": -40, "userhorizon": [20, 27, 22, 20],
"pvforecast2_surface_tilt": 60, "inverter_paco": 10000,
"pvforecast2_userhorizon": [60, 30, 0, 30], },
"pvforecast2_inverter_paco": 2000, {
"pvforecast3_peakpower": 1.6, "peakpower": 4.8,
"pvforecast3_surface_azimuth": 5, "surface_azimuth": -90,
"pvforecast3_surface_tilt": 45, "surface_tilt": 7,
"pvforecast3_userhorizon": [45, 25, 30, 60], "userhorizon": [30, 30, 30, 50],
"pvforecast3_inverter_paco": 1400, "inverter_paco": 10000,
"pvforecast4_peakpower": None, },
{
"peakpower": 1.4,
"surface_azimuth": -40,
"surface_tilt": 60,
"userhorizon": [60, 30, 0, 30],
"inverter_paco": 2000,
},
{
"peakpower": 1.6,
"surface_azimuth": 5,
"surface_tilt": 45,
"userhorizon": [45, 25, 30, 60],
"inverter_paco": 1400,
},
],
},
} }
# Merge settings to config # Merge settings to config
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
assert config_eos.pvforecast.provider == "PVForecastAkkudoktor"
return config_eos return config_eos
@@ -141,15 +157,25 @@ sample_value = AkkudoktorForecastValue(
windspeed_10m=10.0, windspeed_10m=10.0,
) )
sample_config_data = { sample_config_data = {
"prediction_hours": 48, "general": {
"prediction_historic_hours": 24,
"latitude": 52.52, "latitude": 52.52,
"longitude": 13.405, "longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor", },
"pvforecast0_peakpower": 5.0, "prediction": {
"pvforecast0_surface_azimuth": 180, "hours": 48,
"pvforecast0_surface_tilt": 30, "historic_hours": 24,
"pvforecast0_inverter_paco": 10000, },
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"planes": [
{
"peakpower": 5.0,
"surface_azimuth": 180,
"surface_tilt": 30,
"inverter_paco": 10000,
}
],
},
} }

View File

@@ -13,12 +13,16 @@ FILE_TESTDATA_PVFORECASTIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.js
@pytest.fixture @pytest.fixture
def pvforecast_provider(sample_import_1_json, config_eos): def provider(sample_import_1_json, config_eos):
"""Fixture to create a PVForecastProvider instance.""" """Fixture to create a PVForecastProvider instance."""
settings = { settings = {
"pvforecast_provider": "PVForecastImport", "pvforecast": {
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON), "provider": "PVForecastImport",
"pvforecastimport_json": json.dumps(sample_import_1_json), "provider_settings": {
"import_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
"import_json": json.dumps(sample_import_1_json),
},
}
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
provider = PVForecastImport() provider = PVForecastImport()
@@ -39,20 +43,24 @@ def sample_import_1_json():
# ------------------------------------------------ # ------------------------------------------------
def test_singleton_instance(pvforecast_provider): def test_singleton_instance(provider):
"""Test that PVForecastForecast behaves as a singleton.""" """Test that PVForecastForecast behaves as a singleton."""
another_instance = PVForecastImport() another_instance = PVForecastImport()
assert pvforecast_provider is another_instance assert provider is another_instance
def test_invalid_provider(pvforecast_provider, config_eos): def test_invalid_provider(provider, config_eos):
"""Test requesting an unsupported pvforecast_provider.""" """Test requesting an unsupported provider."""
settings = { settings = {
"pvforecast_provider": "<invalid>", "pvforecast": {
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON), "provider": "<invalid>",
"provider_settings": {
"import_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
},
}
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
assert not pvforecast_provider.enabled() assert not provider.enabled()
# ------------------------------------------------ # ------------------------------------------------
@@ -73,35 +81,33 @@ def test_invalid_provider(pvforecast_provider, config_eos):
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day) ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
], ],
) )
def test_import(pvforecast_provider, sample_import_1_json, start_datetime, from_file, config_eos): def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
"""Test fetching forecast from import.""" """Test fetching forecast from import."""
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file: if from_file:
config_eos.pvforecastimport_json = None config_eos.pvforecast.provider_settings.import_json = None
assert config_eos.pvforecastimport_json is None assert config_eos.pvforecast.provider_settings.import_json is None
else: else:
config_eos.pvforecastimport_file_path = None config_eos.pvforecast.provider_settings.import_file_path = None
assert config_eos.pvforecastimport_file_path is None assert config_eos.pvforecast.provider_settings.import_file_path is None
pvforecast_provider.clear() provider.clear()
# Call the method # Call the method
pvforecast_provider.update_data() provider.update_data()
# Assert: Verify the result is as expected # Assert: Verify the result is as expected
assert pvforecast_provider.start_datetime is not None assert provider.start_datetime is not None
assert pvforecast_provider.total_hours is not None assert provider.total_hours is not None
assert compare_datetimes(pvforecast_provider.start_datetime, ems_eos.start_datetime).equal assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
values = sample_import_1_json["pvforecast_ac_power"] values = sample_import_1_json["pvforecast_ac_power"]
value_datetime_mapping = pvforecast_provider.import_datetimes( value_datetime_mapping = provider.import_datetimes(ems_eos.start_datetime, len(values))
ems_eos.start_datetime, len(values)
)
for i, mapping in enumerate(value_datetime_mapping): for i, mapping in enumerate(value_datetime_mapping):
assert i < len(pvforecast_provider.records) assert i < len(provider.records)
expected_datetime, expected_value_index = mapping expected_datetime, expected_value_index = mapping
expected_value = values[expected_value_index] expected_value = values[expected_value_index]
result_datetime = pvforecast_provider.records[i].date_time result_datetime = provider.records[i].date_time
result_value = pvforecast_provider.records[i]["pvforecast_ac_power"] result_value = provider.records[i]["pvforecast_ac_power"]
# print(f"{i}: Expected: {expected_datetime}:{expected_value}") # print(f"{i}: Expected: {expected_datetime}:{expected_value}")
# print(f"{i}: Result: {result_datetime}:{result_value}") # print(f"{i}: Result: {result_datetime}:{result_value}")

View File

@@ -6,8 +6,8 @@ import requests
def test_server(server, config_eos): def test_server(server, config_eos):
"""Test the server.""" """Test the server."""
# validate correct path in server # validate correct path in server
assert config_eos.data_folder_path is not None assert config_eos.general.data_folder_path is not None
assert config_eos.data_folder_path.is_dir() assert config_eos.general.data_folder_path.is_dir()
result = requests.get(f"{server}/v1/config") result = requests.get(f"{server}/v1/config")
assert result.status_code == HTTPStatus.OK assert result.status_code == HTTPStatus.OK

View File

@@ -13,7 +13,7 @@ reference_file = DIR_TESTDATA / "test_example_report.pdf"
def test_generate_pdf_example(config_eos): def test_generate_pdf_example(config_eos):
"""Test generation of example visualization report.""" """Test generation of example visualization report."""
output_dir = config_eos.data_output_path output_dir = config_eos.general.data_output_path
assert output_dir is not None assert output_dir is not None
output_file = output_dir / filename output_file = output_dir / filename
assert not output_file.exists() assert not output_file.exists()

View File

@@ -17,11 +17,11 @@ FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_b
@pytest.fixture @pytest.fixture
def weather_provider(monkeypatch): def provider(monkeypatch):
"""Fixture to create a WeatherProvider instance.""" """Fixture to create a WeatherProvider instance."""
monkeypatch.setenv("weather_provider", "BrightSky") monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "BrightSky")
monkeypatch.setenv("latitude", "50.0") monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "50.0")
monkeypatch.setenv("longitude", "10.0") monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "10.0")
return WeatherBrightSky() return WeatherBrightSky()
@@ -52,27 +52,27 @@ def cache_store():
# ------------------------------------------------ # ------------------------------------------------
def test_singleton_instance(weather_provider): def test_singleton_instance(provider):
"""Test that WeatherForecast behaves as a singleton.""" """Test that WeatherForecast behaves as a singleton."""
another_instance = WeatherBrightSky() another_instance = WeatherBrightSky()
assert weather_provider is another_instance assert provider is another_instance
def test_invalid_provider(weather_provider, monkeypatch): def test_invalid_provider(provider, monkeypatch):
"""Test requesting an unsupported weather_provider.""" """Test requesting an unsupported provider."""
monkeypatch.setenv("weather_provider", "<invalid>") monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "<invalid>")
weather_provider.config.update() provider.config.reset_settings()
assert not weather_provider.enabled() assert not provider.enabled()
def test_invalid_coordinates(weather_provider, monkeypatch): def test_invalid_coordinates(provider, monkeypatch):
"""Test invalid coordinates raise ValueError.""" """Test invalid coordinates raise ValueError."""
monkeypatch.setenv("latitude", "1000") monkeypatch.setenv("EOS_GENERAL__LATITUDE", "1000")
monkeypatch.setenv("longitude", "1000") monkeypatch.setenv("EOS_GENERAL__LONGITUDE", "1000")
with pytest.raises( with pytest.raises(
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range." ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
): ):
weather_provider.config.update() provider.config.reset_settings()
# ------------------------------------------------ # ------------------------------------------------
@@ -80,15 +80,13 @@ def test_invalid_coordinates(weather_provider, monkeypatch):
# ------------------------------------------------ # ------------------------------------------------
def test_irridiance_estimate_from_cloud_cover(weather_provider): def test_irridiance_estimate_from_cloud_cover(provider):
"""Test cloud cover to irradiance estimation.""" """Test cloud cover to irradiance estimation."""
cloud_cover_data = pd.Series( cloud_cover_data = pd.Series(
data=[20, 50, 80], index=pd.date_range("2023-10-22", periods=3, freq="h") data=[20, 50, 80], index=pd.date_range("2023-10-22", periods=3, freq="h")
) )
ghi, dni, dhi = weather_provider.estimate_irradiance_from_cloud_cover( ghi, dni, dhi = provider.estimate_irradiance_from_cloud_cover(50.0, 10.0, cloud_cover_data)
50.0, 10.0, cloud_cover_data
)
assert ghi == [0, 0, 0] assert ghi == [0, 0, 0]
assert dhi == [0, 0, 0] assert dhi == [0, 0, 0]
@@ -101,7 +99,7 @@ def test_irridiance_estimate_from_cloud_cover(weather_provider):
@patch("requests.get") @patch("requests.get")
def test_request_forecast(mock_get, weather_provider, sample_brightsky_1_json): def test_request_forecast(mock_get, provider, sample_brightsky_1_json):
"""Test requesting forecast from BrightSky.""" """Test requesting forecast from BrightSky."""
# Mock response object # Mock response object
mock_response = Mock() mock_response = Mock()
@@ -110,10 +108,10 @@ def test_request_forecast(mock_get, weather_provider, sample_brightsky_1_json):
mock_get.return_value = mock_response mock_get.return_value = mock_response
# Preset, as this is usually done by update() # Preset, as this is usually done by update()
weather_provider.config.update() provider.config.update()
# Test function # Test function
brightsky_data = weather_provider._request_forecast() brightsky_data = provider._request_forecast()
assert isinstance(brightsky_data, dict) assert isinstance(brightsky_data, dict)
assert brightsky_data["weather"][0] == { assert brightsky_data["weather"][0] == {
@@ -150,7 +148,7 @@ def test_request_forecast(mock_get, weather_provider, sample_brightsky_1_json):
@patch("requests.get") @patch("requests.get")
def test_update_data(mock_get, weather_provider, sample_brightsky_1_json, cache_store): def test_update_data(mock_get, provider, sample_brightsky_1_json, cache_store):
"""Test fetching forecast from BrightSky.""" """Test fetching forecast from BrightSky."""
# Mock response object # Mock response object
mock_response = Mock() mock_response = Mock()
@@ -163,14 +161,14 @@ def test_update_data(mock_get, weather_provider, sample_brightsky_1_json, cache_
# Call the method # Call the method
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin"))
weather_provider.update_data(force_enable=True, force_update=True) provider.update_data(force_enable=True, force_update=True)
# Assert: Verify the result is as expected # Assert: Verify the result is as expected
mock_get.assert_called_once() mock_get.assert_called_once()
assert len(weather_provider) == 338 assert len(provider) == 338
# with open(FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON, "w") as f_out: # with open(FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON, "w") as f_out:
# f_out.write(weather_provider.to_json()) # f_out.write(provider.to_json())
# ------------------------------------------------ # ------------------------------------------------
@@ -179,14 +177,14 @@ def test_update_data(mock_get, weather_provider, sample_brightsky_1_json, cache_
@pytest.mark.skip(reason="For development only") @pytest.mark.skip(reason="For development only")
def test_brightsky_development_forecast_data(weather_provider): def test_brightsky_development_forecast_data(provider):
"""Fetch data from real BrightSky server.""" """Fetch data from real BrightSky server."""
# Preset, as this is usually done by update_data() # Preset, as this is usually done by update_data()
weather_provider.start_datetime = to_datetime("2024-10-26 00:00:00") provider.start_datetime = to_datetime("2024-10-26 00:00:00")
weather_provider.latitude = 50.0 provider.latitude = 50.0
weather_provider.longitude = 10.0 provider.longitude = 10.0
brightsky_data = weather_provider._request_forecast() brightsky_data = provider._request_forecast()
with open(FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON, "w") as f_out: with open(FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON, "w") as f_out:
json.dump(brightsky_data, f_out, indent=4) json.dump(brightsky_data, f_out, indent=4)

View File

@@ -21,12 +21,16 @@ FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA = DIR_TESTDATA.joinpath("weatherforecas
@pytest.fixture @pytest.fixture
def weather_provider(config_eos): def provider(config_eos):
"""Fixture to create a WeatherProvider instance.""" """Fixture to create a WeatherProvider instance."""
settings = { settings = {
"weather_provider": "ClearOutside", "weather": {
"provider": "ClearOutside",
},
"general": {
"latitude": 50.0, "latitude": 50.0,
"longitude": 10.0, "longitude": 10.0,
},
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
return WeatherClearOutside() return WeatherClearOutside()
@@ -60,27 +64,33 @@ def cache_store():
# ------------------------------------------------ # ------------------------------------------------
def test_singleton_instance(weather_provider): def test_singleton_instance(provider):
"""Test that WeatherForecast behaves as a singleton.""" """Test that WeatherForecast behaves as a singleton."""
another_instance = WeatherClearOutside() another_instance = WeatherClearOutside()
assert weather_provider is another_instance assert provider is another_instance
def test_invalid_provider(weather_provider, config_eos): def test_invalid_provider(provider, config_eos):
"""Test requesting an unsupported weather_provider.""" """Test requesting an unsupported provider."""
settings = { settings = {
"weather_provider": "<invalid>", "weather": {
"provider": "<invalid>",
}
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
assert not weather_provider.enabled() assert not provider.enabled()
def test_invalid_coordinates(weather_provider, config_eos): def test_invalid_coordinates(provider, config_eos):
"""Test invalid coordinates raise ValueError.""" """Test invalid coordinates raise ValueError."""
settings = { settings = {
"weather_provider": "ClearOutside", "weather": {
"provider": "ClearOutside",
},
"general": {
"latitude": 1000.0, "latitude": 1000.0,
"longitude": 1000.0, "longitude": 1000.0,
},
} }
with pytest.raises( with pytest.raises(
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range." ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
@@ -93,15 +103,13 @@ def test_invalid_coordinates(weather_provider, config_eos):
# ------------------------------------------------ # ------------------------------------------------
def test_irridiance_estimate_from_cloud_cover(weather_provider): def test_irridiance_estimate_from_cloud_cover(provider):
"""Test cloud cover to irradiance estimation.""" """Test cloud cover to irradiance estimation."""
cloud_cover_data = pd.Series( cloud_cover_data = pd.Series(
data=[20, 50, 80], index=pd.date_range("2023-10-22", periods=3, freq="h") data=[20, 50, 80], index=pd.date_range("2023-10-22", periods=3, freq="h")
) )
ghi, dni, dhi = weather_provider.estimate_irradiance_from_cloud_cover( ghi, dni, dhi = provider.estimate_irradiance_from_cloud_cover(50.0, 10.0, cloud_cover_data)
50.0, 10.0, cloud_cover_data
)
assert ghi == [0, 0, 0] assert ghi == [0, 0, 0]
assert dhi == [0, 0, 0] assert dhi == [0, 0, 0]
@@ -114,7 +122,7 @@ def test_irridiance_estimate_from_cloud_cover(weather_provider):
@patch("requests.get") @patch("requests.get")
def test_request_forecast(mock_get, weather_provider, sample_clearout_1_html, config_eos): def test_request_forecast(mock_get, provider, sample_clearout_1_html, config_eos):
"""Test fetching forecast from ClearOutside.""" """Test fetching forecast from ClearOutside."""
# Mock response object # Mock response object
mock_response = Mock() mock_response = Mock()
@@ -126,14 +134,14 @@ def test_request_forecast(mock_get, weather_provider, sample_clearout_1_html, co
config_eos.update() config_eos.update()
# Test function # Test function
response = weather_provider._request_forecast() response = provider._request_forecast()
assert response.status_code == 200 assert response.status_code == 200
assert response.content == sample_clearout_1_html assert response.content == sample_clearout_1_html
@patch("requests.get") @patch("requests.get")
def test_update_data(mock_get, weather_provider, sample_clearout_1_html, sample_clearout_1_data): def test_update_data(mock_get, provider, sample_clearout_1_html, sample_clearout_1_data):
# Mock response object # Mock response object
mock_response = Mock() mock_response = Mock()
mock_response.status_code = 200 mock_response.status_code = 200
@@ -147,17 +155,17 @@ def test_update_data(mock_get, weather_provider, sample_clearout_1_html, sample_
# Call the method # Call the method
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(expected_start) ems_eos.set_start_datetime(expected_start)
weather_provider.update_data() provider.update_data()
# Check for correct prediction time window # Check for correct prediction time window
assert weather_provider.config.prediction_hours == 48 assert provider.config.prediction.hours == 48
assert weather_provider.config.prediction_historic_hours == 48 assert provider.config.prediction.historic_hours == 48
assert compare_datetimes(weather_provider.start_datetime, expected_start).equal assert compare_datetimes(provider.start_datetime, expected_start).equal
assert compare_datetimes(weather_provider.end_datetime, expected_end).equal assert compare_datetimes(provider.end_datetime, expected_end).equal
assert compare_datetimes(weather_provider.keep_datetime, expected_keep).equal assert compare_datetimes(provider.keep_datetime, expected_keep).equal
# Verify the data # Verify the data
assert len(weather_provider) == 165 # 6 days, 24 hours per day - 7th day 21 hours assert len(provider) == 165 # 6 days, 24 hours per day - 7th day 21 hours
# Check that specific values match the expected output # Check that specific values match the expected output
# for i, record in enumerate(weather_data.records): # for i, record in enumerate(weather_data.records):
@@ -169,7 +177,7 @@ def test_update_data(mock_get, weather_provider, sample_clearout_1_html, sample_
@pytest.mark.skip(reason="Test fixture to be improved") @pytest.mark.skip(reason="Test fixture to be improved")
@patch("requests.get") @patch("requests.get")
def test_cache_forecast(mock_get, weather_provider, sample_clearout_1_html, cache_store): def test_cache_forecast(mock_get, provider, sample_clearout_1_html, cache_store):
"""Test that ClearOutside forecast data is cached with TTL. """Test that ClearOutside forecast data is cached with TTL.
This can not be tested with mock_get. Mock objects are not pickable and therefor can not be This can not be tested with mock_get. Mock objects are not pickable and therefor can not be
@@ -183,12 +191,12 @@ def test_cache_forecast(mock_get, weather_provider, sample_clearout_1_html, cach
cache_store.clear(clear_all=True) cache_store.clear(clear_all=True)
weather_provider.update_data() provider.update_data()
mock_get.assert_called_once() mock_get.assert_called_once()
forecast_data_first = weather_provider.to_json() forecast_data_first = provider.to_json()
weather_provider.update_data() provider.update_data()
forecast_data_second = weather_provider.to_json() forecast_data_second = provider.to_json()
# Verify that cache returns the same object without calling the method again # Verify that cache returns the same object without calling the method again
assert forecast_data_first == forecast_data_second assert forecast_data_first == forecast_data_second
# A mock object is not pickable and therefor can not be chached to file # A mock object is not pickable and therefor can not be chached to file
@@ -202,7 +210,7 @@ def test_cache_forecast(mock_get, weather_provider, sample_clearout_1_html, cach
@pytest.mark.skip(reason="For development only") @pytest.mark.skip(reason="For development only")
@patch("requests.get") @patch("requests.get")
def test_development_forecast_data(mock_get, weather_provider, sample_clearout_1_html): def test_development_forecast_data(mock_get, provider, sample_clearout_1_html):
# Mock response object # Mock response object
mock_response = Mock() mock_response = Mock()
mock_response.status_code = 200 mock_response.status_code = 200
@@ -210,14 +218,14 @@ def test_development_forecast_data(mock_get, weather_provider, sample_clearout_1
mock_get.return_value = mock_response mock_get.return_value = mock_response
# Fill the instance # Fill the instance
weather_provider.update_data(force_enable=True) provider.update_data(force_enable=True)
with open(FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA, "w", encoding="utf8") as f_out: with open(FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA, "w", encoding="utf8") as f_out:
f_out.write(weather_provider.to_json()) f_out.write(provider.to_json())
@pytest.mark.skip(reason="For development only") @pytest.mark.skip(reason="For development only")
def test_clearoutsides_development_scraper(weather_provider, sample_clearout_1_html): def test_clearoutsides_development_scraper(provider, sample_clearout_1_html):
"""Test scraping from ClearOutside.""" """Test scraping from ClearOutside."""
soup = BeautifulSoup(sample_clearout_1_html, "html.parser") soup = BeautifulSoup(sample_clearout_1_html, "html.parser")

View File

@@ -13,12 +13,16 @@ FILE_TESTDATA_WEATHERIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json"
@pytest.fixture @pytest.fixture
def weather_provider(sample_import_1_json, config_eos): def provider(sample_import_1_json, config_eos):
"""Fixture to create a WeatherProvider instance.""" """Fixture to create a WeatherProvider instance."""
settings = { settings = {
"weather_provider": "WeatherImport", "weather": {
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON), "provider": "WeatherImport",
"weatherimport_json": json.dumps(sample_import_1_json), "provider_settings": {
"import_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
"import_json": json.dumps(sample_import_1_json),
},
}
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
provider = WeatherImport() provider = WeatherImport()
@@ -39,20 +43,24 @@ def sample_import_1_json():
# ------------------------------------------------ # ------------------------------------------------
def test_singleton_instance(weather_provider): def test_singleton_instance(provider):
"""Test that WeatherForecast behaves as a singleton.""" """Test that WeatherForecast behaves as a singleton."""
another_instance = WeatherImport() another_instance = WeatherImport()
assert weather_provider is another_instance assert provider is another_instance
def test_invalid_provider(weather_provider, config_eos, monkeypatch): def test_invalid_provider(provider, config_eos, monkeypatch):
"""Test requesting an unsupported weather_provider.""" """Test requesting an unsupported provider."""
settings = { settings = {
"weather_provider": "<invalid>", "weather": {
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON), "provider": "<invalid>",
"provider_settings": {
"import_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
},
}
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
assert weather_provider.enabled() == False assert provider.enabled() == False
# ------------------------------------------------ # ------------------------------------------------
@@ -73,33 +81,33 @@ def test_invalid_provider(weather_provider, config_eos, monkeypatch):
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day) ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
], ],
) )
def test_import(weather_provider, sample_import_1_json, start_datetime, from_file, config_eos): def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
"""Test fetching forecast from Import.""" """Test fetching forecast from Import."""
ems_eos = get_ems() ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file: if from_file:
config_eos.weatherimport_json = None config_eos.weather.provider_settings.import_json = None
assert config_eos.weatherimport_json is None assert config_eos.weather.provider_settings.import_json is None
else: else:
config_eos.weatherimport_file_path = None config_eos.weather.provider_settings.import_file_path = None
assert config_eos.weatherimport_file_path is None assert config_eos.weather.provider_settings.import_file_path is None
weather_provider.clear() provider.clear()
# Call the method # Call the method
weather_provider.update_data() provider.update_data()
# Assert: Verify the result is as expected # Assert: Verify the result is as expected
assert weather_provider.start_datetime is not None assert provider.start_datetime is not None
assert weather_provider.total_hours is not None assert provider.total_hours is not None
assert compare_datetimes(weather_provider.start_datetime, ems_eos.start_datetime).equal assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
values = sample_import_1_json["weather_temp_air"] values = sample_import_1_json["weather_temp_air"]
value_datetime_mapping = weather_provider.import_datetimes(ems_eos.start_datetime, len(values)) value_datetime_mapping = provider.import_datetimes(ems_eos.start_datetime, len(values))
for i, mapping in enumerate(value_datetime_mapping): for i, mapping in enumerate(value_datetime_mapping):
assert i < len(weather_provider.records) assert i < len(provider.records)
expected_datetime, expected_value_index = mapping expected_datetime, expected_value_index = mapping
expected_value = values[expected_value_index] expected_value = values[expected_value_index]
result_datetime = weather_provider.records[i].date_time result_datetime = provider.records[i].date_time
result_value = weather_provider.records[i]["weather_temp_air"] result_value = provider.records[i]["weather_temp_air"]
# print(f"{i}: Expected: {expected_datetime}:{expected_value}") # print(f"{i}: Expected: {expected_datetime}:{expected_value}")
# print(f"{i}: Result: {result_datetime}:{result_value}") # print(f"{i}: Result: {result_datetime}:{result_value}")

View File

@@ -1,110 +0,0 @@
{
"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_provider": null,
"elecpriceimport_file_path": null,
"latitude": null,
"load_import_file_path": null,
"load_name": null,
"load_provider": null,
"loadakkudoktor_year_energy": null,
"longitude": null,
"optimization_ev_available_charge_rates_percent": [],
"optimization_hours": 24,
"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": null,
"pvforecast0_surface_tilt": null,
"pvforecast0_trackingtype": null,
"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": null,
"pvforecast1_surface_tilt": null,
"pvforecast1_trackingtype": null,
"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": null,
"pvforecast2_surface_tilt": null,
"pvforecast2_trackingtype": null,
"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": null,
"pvforecast3_surface_tilt": null,
"pvforecast3_trackingtype": null,
"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": null,
"pvforecast4_surface_tilt": null,
"pvforecast4_trackingtype": null,
"pvforecast4_userhorizon": null,
"pvforecast_provider": null,
"pvforecastimport_file_path": null,
"server_eos_host": "0.0.0.0",
"server_eos_port": 8503,
"server_eosdash_host": "0.0.0.0",
"server_eosdash_port": 8504,
"weather_provider": null,
"weatherimport_file_path": null
}

View File

@@ -26,15 +26,19 @@
] ]
}, },
"pv_akku": { "pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400, "capacity_wh": 26400,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"initial_soc_percentage": 80, "initial_soc_percentage": 80,
"min_soc_percentage": 15 "min_soc_percentage": 15
}, },
"inverter": { "inverter": {
"max_power_wh": 10000 "device_id": "inverter1",
"max_power_wh": 10000,
"battery_id": "battery1"
}, },
"eauto": { "eauto": {
"device_id": "ev1",
"capacity_wh": 60000, "capacity_wh": 60000,
"charging_efficiency": 0.95, "charging_efficiency": 0.95,
"discharging_efficiency": 1.0, "discharging_efficiency": 1.0,

View File

@@ -154,6 +154,7 @@
] ]
}, },
"pv_akku": { "pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400, "capacity_wh": 26400,
"initial_soc_percentage": 80, "initial_soc_percentage": 80,
"min_soc_percentage": 0 "min_soc_percentage": 0
@@ -162,13 +163,20 @@
"max_power_wh": 10000 "max_power_wh": 10000
}, },
"eauto": { "eauto": {
"device_id": "ev1",
"capacity_wh": 60000, "capacity_wh": 60000,
"charging_efficiency": 0.95, "charging_efficiency": 0.95,
"max_charge_power_w": 11040, "max_charge_power_w": 11040,
"initial_soc_percentage": 5, "initial_soc_percentage": 5,
"min_soc_percentage": 80 "min_soc_percentage": 80
}, },
"inverter": {
"device_id": "inverter1",
"max_power_wh": 10000,
"battery_id": "battery1"
},
"dishwasher": { "dishwasher": {
"device_id": "dishwasher1",
"consumption_wh": 5000, "consumption_wh": 5000,
"duration_h": 2 "duration_h": 2
}, },

View File

@@ -557,6 +557,7 @@
] ]
}, },
"eauto_obj": { "eauto_obj": {
"device_id": "ev1",
"charge_array": [ "charge_array": [
1.0, 1.0,
1.0, 1.0,

View File

@@ -606,6 +606,7 @@
] ]
}, },
"eauto_obj": { "eauto_obj": {
"device_id": "ev1",
"charge_array": [ "charge_array": [
1.0, 1.0,
1.0, 1.0,

View File

@@ -606,6 +606,7 @@
] ]
}, },
"eauto_obj": { "eauto_obj": {
"device_id": "ev1",
"charge_array": [ "charge_array": [
1.0, 1.0,
1.0, 1.0,