mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-10-11 11:56:17 +00:00
Compare commits
18 Commits
NormannK-p
...
dl_dev-arc
Author | SHA1 | Date | |
---|---|---|---|
|
87ac127817 | ||
|
480adf8100 | ||
|
90688a36f2 | ||
|
6516455071 | ||
|
84683cd195 | ||
|
26762e5e93 | ||
|
56403fe053 | ||
|
5bd8321e95 | ||
|
c1dd31528b | ||
|
1658b491d2 | ||
|
af5e4a753a | ||
|
e0b1ece524 | ||
|
437d38f508 | ||
|
95be7b914f | ||
|
3257dac92b | ||
|
1e1bac9fdb | ||
|
d74a56b75a | ||
|
be26457563 |
@@ -1,8 +1,8 @@
|
||||
.git/
|
||||
.github/
|
||||
eos-data/
|
||||
mariadb-data/
|
||||
test_data/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.egg-info/
|
||||
.dockerignore
|
||||
.env
|
||||
.gitignore
|
||||
@@ -12,4 +12,4 @@ LICENSE
|
||||
Makefile
|
||||
NOTICE
|
||||
README.md
|
||||
.venv
|
||||
.venv/
|
||||
|
5
.env
5
.env
@@ -1,4 +1,7 @@
|
||||
EOS_VERSION=main
|
||||
EOS_PORT=8503
|
||||
EOS_SERVER__PORT=8503
|
||||
EOS_SERVER__EOSDASH_PORT=8504
|
||||
|
||||
PYTHON_VERSION=3.12.6
|
||||
BASE_IMAGE=python
|
||||
IMAGE_SUFFIX=-slim
|
||||
|
89
.github/workflows/docker-build.yml
vendored
89
.github/workflows/docker-build.yml
vendored
@@ -7,13 +7,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'feature/config-overhaul'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'feature/config-overhaul'
|
||||
- '**'
|
||||
|
||||
env:
|
||||
DOCKERHUB_REPO: akkudoktor/eos
|
||||
@@ -40,7 +38,9 @@ jobs:
|
||||
run: |
|
||||
if ${{ github.event_name == 'pull_request' }}; then
|
||||
echo 'matrix=[
|
||||
{"platform": "linux/arm64"}
|
||||
{"platform": {"name": "linux/amd64"}},
|
||||
{"platform": {"name": "linux/arm64"}},
|
||||
{"platform": {"name": "linux/386"}},
|
||||
]' | tr -d '[:space:]' >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo 'matrix=[]' >> $GITHUB_OUTPUT
|
||||
@@ -58,13 +58,69 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- name: linux/amd64
|
||||
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) }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
platform=${{ matrix.platform.name }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker meta
|
||||
@@ -98,7 +154,8 @@ jobs:
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
# skip for pull requests
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
#TODO: uncomment again
|
||||
#if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -106,8 +163,7 @@ jobs:
|
||||
|
||||
- name: Set up QEMU
|
||||
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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -116,10 +172,19 @@ jobs:
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
platforms: ${{ matrix.platform.name }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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
|
||||
uses: actions/attest-build-provenance@v2
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -260,3 +260,6 @@ tests/testdata/new_optimize_result*
|
||||
tests/testdata/openapi-new.json
|
||||
tests/testdata/openapi-new.md
|
||||
tests/testdata/config-new.md
|
||||
|
||||
# FastHTML session key
|
||||
.sesskey
|
||||
|
90
Dockerfile
90
Dockerfile
@@ -1,10 +1,10 @@
|
||||
ARG PYTHON_VERSION=3.12.7
|
||||
FROM python:${PYTHON_VERSION}-slim
|
||||
ARG PYTHON_VERSION=3.12.8
|
||||
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"
|
||||
|
||||
ENV VIRTUAL_ENV="/opt/venv"
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
ENV MPLCONFIGDIR="/tmp/mplconfigdir"
|
||||
ENV EOS_DIR="/opt/eos"
|
||||
ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
|
||||
@@ -13,7 +13,8 @@ ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
|
||||
|
||||
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}" \
|
||||
&& chown eos "${MPLCONFIGDIR}" \
|
||||
&& mkdir -p "${EOS_CACHE_DIR}" \
|
||||
@@ -23,13 +24,85 @@ RUN adduser --system --group --no-create-home eos \
|
||||
&& mkdir -p "${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 .
|
||||
|
||||
# Use tmpfs for cargo due to qemu (multiarch) limitations
|
||||
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 .
|
||||
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
|
||||
|
||||
@@ -39,6 +112,7 @@ ENTRYPOINT []
|
||||
EXPOSE 8503
|
||||
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}"]
|
||||
|
@@ -52,6 +52,8 @@ Windows:
|
||||
docker compose up
|
||||
```
|
||||
|
||||
If you are running the EOS container on a system hosting multiple services, such as a Synology NAS, and want to allow external network access to EOS, please ensure that the default exported ports (8503, 8504) are available on the host. On Synology systems, these ports might already be in use (refer to [this guide](https://kb.synology.com/en-me/DSM/tutorial/What_network_ports_are_used_by_Synology_services)). If the ports are occupied, you will need to reconfigure the exported ports accordingly.
|
||||
|
||||
## Configuration
|
||||
|
||||
This project uses the `EOS.config.json` file to manage configuration settings.
|
||||
|
@@ -11,12 +11,21 @@ services:
|
||||
dockerfile: "Dockerfile"
|
||||
args:
|
||||
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:
|
||||
- EOS_CONFIG_DIR=config
|
||||
- latitude=52.2
|
||||
- longitude=13.4
|
||||
- elecprice_provider=ElecPriceAkkudoktor
|
||||
- elecprice_charges_kwh=0.21
|
||||
- server_fasthtml_host=none
|
||||
- EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
|
||||
- EOS_PREDICTION__LATITUDE=52.2
|
||||
- EOS_PREDICTION__LONGITUDE=13.4
|
||||
- EOS_ELECPRICE__PROVIDER=ElecPriceAkkudoktor
|
||||
- EOS_ELECPRICE__CHARGES_KWH=0.21
|
||||
ports:
|
||||
- "${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
@@ -63,7 +63,7 @@ Args:
|
||||
year_energy (float): Yearly energy consumption in Wh.
|
||||
|
||||
Note:
|
||||
Set LoadAkkudoktor as load_provider, then update data with
|
||||
Set LoadAkkudoktor as provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=load_mean' instead.
|
||||
@@ -91,6 +91,8 @@ Fastapi Optimize
|
||||
|
||||
- `start_hour` (query, optional): Defaults to current hour of the day.
|
||||
|
||||
- `ngen` (query, optional): No description provided.
|
||||
|
||||
**Request Body**:
|
||||
|
||||
- `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.
|
||||
|
||||
Note:
|
||||
Set PVForecastAkkudoktor as pvforecast_provider, then update data with
|
||||
Set PVForecastAkkudoktor as provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=pvforecast_ac_power' and
|
||||
@@ -151,7 +153,7 @@ Note:
|
||||
Electricity price charges are added.
|
||||
|
||||
Note:
|
||||
Set ElecPriceAkkudoktor as elecprice_provider, then update data with
|
||||
Set ElecPriceAkkudoktor as provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=elecprice_marketprice_wh' or
|
||||
@@ -190,11 +192,11 @@ Returns:
|
||||
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
|
||||
value that is None, the configuration will fall back to values from other sources such as
|
||||
environment variables, the EOS configuration file, or default values.
|
||||
Note that for any setting value that is None or unset, the configuration will fall back to
|
||||
values from other sources such as environment variables, the EOS configuration file, or default
|
||||
values.
|
||||
|
||||
Args:
|
||||
settings (SettingsEOS): The settings to write into the current settings.
|
||||
@@ -203,311 +205,11 @@ Returns:
|
||||
configuration (ConfigEOS): The current configuration after the write.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
**Request Body**:
|
||||
|
||||
- `server_eos_host` (query, optional): EOS server IP address.
|
||||
|
||||
- `server_eos_port` (query, optional): EOS server IP port number.
|
||||
|
||||
- `server_eos_verbose` (query, optional): Enable debug output
|
||||
|
||||
- `server_eos_startup_eosdash` (query, optional): EOS server to start EOSdash server.
|
||||
|
||||
- `server_eosdash_host` (query, optional): EOSdash server IP address.
|
||||
|
||||
- `server_eosdash_port` (query, optional): EOSdash server IP port number.
|
||||
|
||||
- `weatherimport_file_path` (query, optional): Path to the file to import weather data from.
|
||||
|
||||
- `weatherimport_json` (query, optional): JSON string, dictionary of weather forecast value lists.
|
||||
|
||||
- `weather_provider` (query, optional): Weather provider id of provider to be used.
|
||||
|
||||
- `pvforecastimport_file_path` (query, optional): Path to the file to import PV forecast data from.
|
||||
|
||||
- `pvforecastimport_json` (query, optional): JSON string, dictionary of PV forecast value lists.
|
||||
|
||||
- `pvforecast_provider` (query, optional): PVForecast provider id of provider to be used.
|
||||
|
||||
- `pvforecast0_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast0_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast0_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast0_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast0_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast0_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast0_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast0_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast0_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast0_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast0_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast0_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast0_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast0_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast0_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast0_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast1_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast1_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast1_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast1_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast1_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast1_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast1_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast1_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast1_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast1_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast1_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast1_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast1_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast1_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast1_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast1_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast2_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast2_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast2_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast2_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast2_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast2_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast2_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast2_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast2_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast2_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast2_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast2_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast2_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast2_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast2_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast2_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast3_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast3_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast3_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast3_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast3_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast3_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast3_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast3_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast3_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast3_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast3_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast3_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast3_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast3_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast3_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast3_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast4_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast4_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast4_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast4_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast4_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast4_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast4_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast4_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast4_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast4_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast4_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast4_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast4_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast4_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast4_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast4_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `pvforecast5_surface_tilt` (query, optional): Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast5_surface_azimuth` (query, optional): Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
|
||||
- `pvforecast5_userhorizon` (query, optional): Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
|
||||
- `pvforecast5_peakpower` (query, optional): Nominal power of PV system in kW.
|
||||
|
||||
- `pvforecast5_pvtechchoice` (query, optional): PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
|
||||
- `pvforecast5_mountingplace` (query, optional): Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
|
||||
- `pvforecast5_loss` (query, optional): Sum of PV system losses in percent
|
||||
|
||||
- `pvforecast5_trackingtype` (query, optional): Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
|
||||
- `pvforecast5_optimal_surface_tilt` (query, optional): Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast5_optimalangles` (query, optional): Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
|
||||
- `pvforecast5_albedo` (query, optional): Proportion of the light hitting the ground that it reflects back.
|
||||
|
||||
- `pvforecast5_module_model` (query, optional): Model of the PV modules of this plane.
|
||||
|
||||
- `pvforecast5_inverter_model` (query, optional): Model of the inverter of this plane.
|
||||
|
||||
- `pvforecast5_inverter_paco` (query, optional): AC power rating of the inverter. [W]
|
||||
|
||||
- `pvforecast5_modules_per_string` (query, optional): Number of the PV modules of the strings of this plane.
|
||||
|
||||
- `pvforecast5_strings_per_inverter` (query, optional): Number of the strings of the inverter of this plane.
|
||||
|
||||
- `load_import_file_path` (query, optional): Path to the file to import load data from.
|
||||
|
||||
- `load_import_json` (query, optional): JSON string, dictionary of load forecast value lists.
|
||||
|
||||
- `loadakkudoktor_year_energy` (query, optional): Yearly energy consumption (kWh).
|
||||
|
||||
- `load_provider` (query, optional): Load provider id of provider to be used.
|
||||
|
||||
- `elecpriceimport_file_path` (query, optional): Path to the file to import elecprice data from.
|
||||
|
||||
- `elecpriceimport_json` (query, optional): JSON string, dictionary of electricity price forecast value lists.
|
||||
|
||||
- `elecprice_provider` (query, optional): Electricity price provider id of provider to be used.
|
||||
|
||||
- `elecprice_charges_kwh` (query, optional): Electricity price charges (€/kWh).
|
||||
|
||||
- `prediction_hours` (query, optional): Number of hours into the future for predictions
|
||||
|
||||
- `prediction_historic_hours` (query, optional): Number of hours into the past for historical predictions data
|
||||
|
||||
- `latitude` (query, optional): Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)
|
||||
|
||||
- `longitude` (query, optional): Longitude in decimal degrees, within -180 to 180 (°)
|
||||
|
||||
- `optimization_hours` (query, optional): Number of hours into the future for optimizations.
|
||||
|
||||
- `optimization_penalty` (query, optional): Penalty factor used in optimization.
|
||||
|
||||
- `optimization_ev_available_charge_rates_percent` (query, optional): Charge rates available for the EV in percent of maximum charge.
|
||||
|
||||
- `measurement_load0_name` (query, optional): Name of the load0 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `measurement_load1_name` (query, optional): Name of the load1 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `measurement_load2_name` (query, optional): Name of the load2 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `measurement_load3_name` (query, optional): Name of the load3 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `measurement_load4_name` (query, optional): Name of the load4 source (e.g. 'Household', 'Heat Pump')
|
||||
|
||||
- `battery_provider` (query, optional): Id of Battery simulation provider.
|
||||
|
||||
- `battery_capacity` (query, optional): Battery capacity [Wh].
|
||||
|
||||
- `battery_initial_soc` (query, optional): Battery initial state of charge [%].
|
||||
|
||||
- `battery_soc_min` (query, optional): Battery minimum state of charge [%].
|
||||
|
||||
- `battery_soc_max` (query, optional): Battery maximum state of charge [%].
|
||||
|
||||
- `battery_charging_efficiency` (query, optional): Battery charging efficiency [%].
|
||||
|
||||
- `battery_discharging_efficiency` (query, optional): Battery discharging efficiency [%].
|
||||
|
||||
- `battery_max_charging_power` (query, optional): Battery maximum charge power [W].
|
||||
|
||||
- `bev_provider` (query, optional): Id of Battery Electric Vehicle simulation provider.
|
||||
|
||||
- `bev_capacity` (query, optional): Battery Electric Vehicle capacity [Wh].
|
||||
|
||||
- `bev_initial_soc` (query, optional): Battery Electric Vehicle initial state of charge [%].
|
||||
|
||||
- `bev_soc_max` (query, optional): Battery Electric Vehicle maximum state of charge [%].
|
||||
|
||||
- `bev_charging_efficiency` (query, optional): Battery Electric Vehicle charging efficiency [%].
|
||||
|
||||
- `bev_discharging_efficiency` (query, optional): Battery Electric Vehicle discharging efficiency [%].
|
||||
|
||||
- `bev_max_charging_power` (query, optional): Battery Electric Vehicle maximum charge power [W].
|
||||
|
||||
- `dishwasher_provider` (query, optional): Id of Dish Washer simulation provider.
|
||||
|
||||
- `dishwasher_consumption` (query, optional): Dish Washer energy consumption [Wh].
|
||||
|
||||
- `dishwasher_duration` (query, optional): Dish Washer usage duration [h].
|
||||
|
||||
- `inverter_provider` (query, optional): Id of PV Inverter simulation provider.
|
||||
|
||||
- `inverter_power_max` (query, optional): Inverter maximum power [W].
|
||||
|
||||
- `logging_level_default` (query, optional): EOS default logging level.
|
||||
|
||||
- `data_folder_path` (query, optional): Path to EOS data directory.
|
||||
|
||||
- `data_output_subpath` (query, optional): Sub-path for the EOS output data directory.
|
||||
|
||||
- `data_cache_subpath` (query, optional): Sub-path for the EOS cache data directory.
|
||||
- `application/json`: {
|
||||
"$ref": "#/components/schemas/SettingsEOS"
|
||||
}
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
```
|
||||
Update the configuration from the EOS configuration file.
|
||||
Reset the configuration to the EOS configuration file.
|
||||
|
||||
Returns:
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
**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
3
docs/_static/eos.css
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.wy-nav-content {
|
||||
max-width: 90% !important;
|
||||
}
|
@@ -7,10 +7,9 @@ management.
|
||||
|
||||
## Storing Configuration
|
||||
|
||||
EOS stores configuration data in a **key-value store**, where a `configuration key` refers to the
|
||||
unique identifier used to store and retrieve specific configuration data. Note that the key-value
|
||||
store is memory-based, meaning all stored data will be lost upon restarting the EOS REST server if
|
||||
not saved to the `EOS configuration file`.
|
||||
EOS stores configuration data in a `nested structure`. Note that configuration changes inside EOS
|
||||
are updated in memory, meaning all changes will be lost upon restarting the EOS REST server if not
|
||||
saved to the `EOS configuration file`.
|
||||
|
||||
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.
|
||||
@@ -25,37 +24,37 @@ Use endpoint `PUT /v1/config/file` to save the current configuration to the
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
3. **EOS Configuration File**: Read at startup of the REST server and on request
|
||||
4. **Default Values**
|
||||
|
||||
### Settings
|
||||
### Runtime Config Updates
|
||||
|
||||
Settings are sets of configuration data that take precedence over all other configuration data from
|
||||
different sources. Note that settings are not persistent. To make the current configuration with the
|
||||
current settings persistent, save the configuration to the `EOS configuration file`.
|
||||
The EOS configuration can be updated at runtime. Note that those updates are not persistent
|
||||
automatically. However it is possible to save the configuration to the `EOS configuration file`.
|
||||
|
||||
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/value`: Sets a specific configuration option.
|
||||
- `PUT /v1/config`: Update the entire or parts of the configuration.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All `configuration keys` can be set by environment variables with the same name. EOS recognizes the
|
||||
following special environment variables:
|
||||
All `configuration keys` can be set by environment variables prefixed with `EOS_` and separated by
|
||||
`__` for nested structures. Environment variables are case insensitive.
|
||||
|
||||
EOS recognizes the following special environment variables (case sensitive):
|
||||
|
||||
- `EOS_CONFIG_DIR`: The directory to search for an EOS configuration file.
|
||||
- `EOS_DIR`: The directory used by EOS for data, which will also be searched for an EOS
|
||||
configuration file.
|
||||
- `EOS_LOGGING_LEVEL`: The logging level to use in EOS.
|
||||
|
||||
### EOS Configuration File
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -75,9 +74,15 @@ EOS searches for the configuration file in the following order:
|
||||
3. A platform-specific default directory for EOS
|
||||
4. The current working directory
|
||||
|
||||
The first available configuration file found in these directories is loaded. If no configuration
|
||||
file is found, a default configuration file is created in the platform-specific default directory,
|
||||
and default settings are loaded into it.
|
||||
The first configuration file available in these directories is loaded. If no configuration file is
|
||||
found, a default configuration file is created, and the default settings are written to it. The
|
||||
location of the created configuration file follows the same order in which EOS searches for
|
||||
configuration files, and it depends on whether the relevant environment variables are set.
|
||||
|
||||
Use the following endpoints to interact with the configuration file:
|
||||
|
||||
- `PUT /v1/config/file`: Save the current configuration to the configuration file.
|
||||
- `PUT /v1/config/reset`: Reload the configuration file, all unsaved runtime configuration is reset.
|
||||
|
||||
### Default Values
|
||||
|
||||
|
@@ -56,21 +56,21 @@ A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html)
|
||||
The EOS measurement store provides for storing meter readings of loads. There are currently five loads
|
||||
foreseen. The associated `measurement key`s are:
|
||||
|
||||
- `measurement_load0_mr`: Load0 meter reading [kWh]
|
||||
- `measurement_load1_mr`: Load1 meter reading [kWh]
|
||||
- `measurement_load2_mr`: Load2 meter reading [kWh]
|
||||
- `measurement_load3_mr`: Load3 meter reading [kWh]
|
||||
- `measurement_load4_mr`: Load4 meter reading [kWh]
|
||||
- `load0_mr`: Load0 meter reading [kWh]
|
||||
- `load1_mr`: Load1 meter reading [kWh]
|
||||
- `load2_mr`: Load2 meter reading [kWh]
|
||||
- `load3_mr`: Load3 meter reading [kWh]
|
||||
- `load4_mr`: Load4 meter reading [kWh]
|
||||
|
||||
For ease of use, you can assign descriptive names to the `measurement key`s to represent your
|
||||
system's load sources. Use the following `configuration options` to set these names
|
||||
(e.g., 'Dish Washer', 'Heat Pump'):
|
||||
|
||||
- `measurement_load0_name`: Name of the load0 source
|
||||
- `measurement_load1_name`: Name of the load1 source
|
||||
- `measurement_load2_name`: Name of the load2 source
|
||||
- `measurement_load3_name`: Name of the load3 source
|
||||
- `measurement_load4_name`: Name of the load4 source
|
||||
- `load0_name`: Name of the load0 source
|
||||
- `load1_name`: Name of the load1 source
|
||||
- `load2_name`: Name of the load2 source
|
||||
- `load3_name`: Name of the load3 source
|
||||
- `load4_name`: Name of the load4 source
|
||||
|
||||
Load measurements can be stored for any datetime. The values between different meter readings are
|
||||
linearly approximated. Since optimization occurs on the hour, storing values between hours is
|
||||
@@ -84,8 +84,8 @@ for specified intervals, usually one hour. This aggregated data can be used for
|
||||
The EOS measurement store also allows for the storage of meter readings for grid import and export.
|
||||
The associated `measurement key`s are:
|
||||
|
||||
- `measurement_grid_export_mr`: Export to grid meter reading [kWh]
|
||||
- `measurement_grid_import_mr`: Import from grid meter reading [kWh]
|
||||
- `grid_export_mr`: Export to grid meter reading [kWh]
|
||||
- `grid_import_mr`: Import from grid meter reading [kWh]
|
||||
|
||||
:::{admonition} Todo
|
||||
:class: note
|
||||
|
@@ -19,10 +19,14 @@ data is lost on re-start of the EOS REST server.
|
||||
## Prediction Providers
|
||||
|
||||
Most predictions can be sourced from various providers. The specific provider to use is configured
|
||||
in the EOS configuration. For example:
|
||||
in the EOS configuration and can be set by prediction type. For example:
|
||||
|
||||
```python
|
||||
weather_provider = "ClearOutside"
|
||||
{
|
||||
"weather": {
|
||||
"provider": "ClearOutside"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## Prediction Updates
|
||||
@@ -107,21 +111,23 @@ Prediction keys:
|
||||
|
||||
Configuration options:
|
||||
|
||||
- `elecprice_provider`: Electricity price provider id of provider to be used.
|
||||
- `elecprice`: Electricity price configuration.
|
||||
|
||||
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `ElecPriceImport`: Imports from a file or JSON string.
|
||||
- `provider`: Electricity price provider id of provider to be used.
|
||||
|
||||
- `elecprice_charges_kwh`: Electricity price charges (€/kWh).
|
||||
- `elecpriceimport_file_path`: Path to the file to import electricity price forecast data from.
|
||||
- `elecpriceimport_json`: JSON string, dictionary of electricity price forecast value lists.
|
||||
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `ElecPriceImport`: Imports from a file or JSON string.
|
||||
|
||||
- `charges_kwh`: Electricity price charges (€/kWh).
|
||||
- `provider_settings.import_file_path`: Path to the file to import electricity price forecast data from.
|
||||
- `provider_settings.import_json`: JSON string, dictionary of electricity price forecast value lists.
|
||||
|
||||
### ElecPriceAkkudoktor Provider
|
||||
|
||||
The `ElecPriceAkkudoktor` provider retrieves electricity prices directly from **Akkudoktor.net**,
|
||||
which supplies price data for the next 24 hours. For periods beyond 24 hours, the provider generates
|
||||
prices by extrapolating historical price data combined with the most recent actual prices obtained
|
||||
from Akkudoktor.net. Electricity price charges given in the `elecprice_charges_kwh` configuration
|
||||
from Akkudoktor.net. Electricity price charges given in the `charges_kwh` configuration
|
||||
option are added.
|
||||
|
||||
### 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
|
||||
<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
|
||||
|
||||
@@ -148,14 +154,16 @@ Prediction keys:
|
||||
|
||||
Configuration options:
|
||||
|
||||
- `load_provider`: Load provider id of provider to be used.
|
||||
- `load`: Load configuration.
|
||||
|
||||
- `LoadAkkudoktor`: Retrieves from local database.
|
||||
- `LoadImport`: Imports from a file or JSON string.
|
||||
- `provider`: Load provider id of provider to be used.
|
||||
|
||||
- `loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
|
||||
- `loadimport_file_path`: Path to the file to import load forecast data from.
|
||||
- `loadimport_json`: JSON string, dictionary of load forecast value lists.
|
||||
- `LoadAkkudoktor`: Retrieves from local database.
|
||||
- `LoadImport`: Imports from a file or JSON string.
|
||||
|
||||
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
|
||||
- `provider_settings.loadimport_file_path`: Path to the file to import load forecast data from.
|
||||
- `provider_settings.loadimport_json`: JSON string, dictionary of load forecast value lists.
|
||||
|
||||
### LoadAkkudoktor Provider
|
||||
|
||||
@@ -188,39 +196,44 @@ Prediction keys:
|
||||
|
||||
Configuration options:
|
||||
|
||||
- `pvforecast_provider`: PVForecast provider id of provider to be used.
|
||||
- `general`: General configuration.
|
||||
|
||||
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `PVForecastImport`: Imports from a file or JSON string.
|
||||
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
|
||||
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
|
||||
|
||||
- `latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
|
||||
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
|
||||
- `pvforecast<0..5>_surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
- `pvforecast<0..5>_surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
- `pvforecast<0..5>_userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW.
|
||||
- `pvforecast<0..5>_pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
- `pvforecast<0..5>_mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
- `pvforecast<0..5>_loss`: Sum of PV system losses in percent
|
||||
- `pvforecast<0..5>_trackingtype`: Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
- `pvforecast<0..5>_optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
- `pvforecast<0..5>_optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
- `pvforecast<0..5>_albedo`: Proportion of the light hitting the ground that it reflects back.
|
||||
- `pvforecast<0..5>_module_model`: Model of the PV modules of this plane.
|
||||
- `pvforecast<0..5>_inverter_model`: Model of the inverter of this plane.
|
||||
- `pvforecast<0..5>_inverter_paco`: AC power rating of the inverter. [W]
|
||||
- `pvforecast<0..5>_modules_per_string`: Number of the PV modules of the strings of this plane.
|
||||
- `pvforecast<0..5>_strings_per_inverter`: Number of the strings of the inverter of this plane.
|
||||
- `pvforecastimport_file_path`: Path to the file to import PV forecast data from.
|
||||
- `pvforecastimport_json`: JSON string, dictionary of PV forecast value lists.
|
||||
- `pvforecast`: PV forecast configuration.
|
||||
|
||||
- `provider`: PVForecast provider id of provider to be used.
|
||||
|
||||
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `PVForecastImport`: Imports from a file or JSON string.
|
||||
|
||||
- `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
- `planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
- `planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
- `planes[].peakpower`: Nominal power of PV system in kW.
|
||||
- `planes[].pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
|
||||
- `planes[].mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
|
||||
- `planes[].loss`: Sum of PV system losses in percent
|
||||
- `planes[].trackingtype`: Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
|
||||
- `planes[].optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking.
|
||||
- `planes[].optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
|
||||
- `planes[].albedo`: Proportion of the light hitting the ground that it reflects back.
|
||||
- `planes[].module_model`: Model of the PV modules of this plane.
|
||||
- `planes[].inverter_model`: Model of the inverter of this plane.
|
||||
- `planes[].inverter_paco`: AC power rating of the inverter. [W]
|
||||
- `planes[].modules_per_string`: Number of the PV modules of the strings of this plane.
|
||||
- `planes[].strings_per_inverter`: Number of the strings of the inverter of this plane.
|
||||
- `provider_settings.import_file_path`: Path to the file to import PV forecast data from.
|
||||
- `provider_settings.import_json`: JSON string, dictionary of PV forecast value lists.
|
||||
|
||||
------
|
||||
|
||||
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**:
|
||||
|
||||
- `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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
- `pvforecast<0..5>_userhorizon`
|
||||
- `userhorizon`
|
||||
|
||||
Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. In the user horizon
|
||||
data each number represents the horizon height in degrees in a certain compass direction around the
|
||||
@@ -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.
|
||||
|
||||
- `pvforecast<0..5>_surface_tilt`:
|
||||
- `surface_tilt`:
|
||||
|
||||
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,
|
||||
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
|
||||
**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) (°)"
|
||||
- `longitude`: Longitude in decimal degrees, within -180 to 180 (°)
|
||||
- `general.latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
|
||||
- `general.longitude`: Longitude in decimal degrees, within -180 to 180 (°)
|
||||
|
||||
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<0..5>_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<0..5>_inverter_paco`: AC power rating of the inverter. [W]
|
||||
- `pvforecast<0..5>_peakpower`: Nominal power of PV system in kW.
|
||||
- `pvforecast.planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
|
||||
- `pvforecast.planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
|
||||
- `pvforecast.planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
|
||||
- `pvforecast.planes[].inverter_paco`: AC power rating of the inverter. [W]
|
||||
- `pvforecast.planes[].peakpower`: Nominal power of PV system in kW.
|
||||
|
||||
Example:
|
||||
|
||||
```Python
|
||||
{
|
||||
"latitude": 50.1234,
|
||||
"longitude": 9.7654,
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
"pvforecast4_peakpower": None,
|
||||
"general": {
|
||||
"latitude": 50.1234,
|
||||
"longitude": 9.7654,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
<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
|
||||
|
||||
@@ -368,14 +394,16 @@ Prediction keys:
|
||||
|
||||
Configuration options:
|
||||
|
||||
- `weather_provider`: Load provider id of provider to be used.
|
||||
- `weather`: General weather configuration.
|
||||
|
||||
- `BrightSky`: Retrieves from https://api.brightsky.dev.
|
||||
- `ClearOutside`: Retrieves from https://clearoutside.com/forecast.
|
||||
- `LoadImport`: Imports from a file or JSON string.
|
||||
- `provider`: Load provider id of provider to be used.
|
||||
|
||||
- `weatherimport_file_path`: Path to the file to import weatherforecast data from.
|
||||
- `weatherimport_json`: JSON string, dictionary of weather forecast value lists.
|
||||
- `BrightSky`: Retrieves from https://api.brightsky.dev.
|
||||
- `ClearOutside`: Retrieves from https://clearoutside.com/forecast.
|
||||
- `LoadImport`: Imports from a file or JSON string.
|
||||
|
||||
- `provider_settings.import_file_path`: Path to the file to import weatherforecast data from.
|
||||
- `provider_settings.import_json`: JSON string, dictionary of weather forecast value lists.
|
||||
|
||||
### 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
|
||||
<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.
|
||||
|
@@ -99,6 +99,7 @@ html_theme_options = {
|
||||
"logo_only": False,
|
||||
"titles_only": True,
|
||||
}
|
||||
html_css_files = ["eos.css"]
|
||||
|
||||
# -- Options for autodoc -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html
|
||||
|
12951
openapi.json
12951
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,17 @@
|
||||
numpy==2.2.2
|
||||
numpydantic==1.6.4
|
||||
numpydantic==1.6.7
|
||||
matplotlib==3.10.0
|
||||
fastapi[standard]==0.115.6
|
||||
fastapi[standard]==0.115.7
|
||||
python-fasthtml==0.12.0
|
||||
uvicorn==0.34.0
|
||||
scikit-learn==1.6.1
|
||||
timezonefinder==6.5.7
|
||||
timezonefinder==6.5.8
|
||||
deap==1.4.2
|
||||
requests==2.32.3
|
||||
pandas==2.2.3
|
||||
pendulum==3.0.0
|
||||
platformdirs==4.3.6
|
||||
pvlib==0.11.2
|
||||
pydantic==2.10.5
|
||||
pydantic==2.10.6
|
||||
statsmodels==0.14.4
|
||||
pydantic-settings==2.7.0
|
||||
|
@@ -2,132 +2,279 @@
|
||||
"""Utility functions for Configuration specification generation."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
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.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
config_eos = get_config()
|
||||
|
||||
# Fixed set of prefixes to filter configuration values and their respective titles
|
||||
CONFIG_PREFIXES = {
|
||||
"battery": "Battery Device Simulation Configuration",
|
||||
"bev": "Battery Electric Vehicle Device Simulation Configuration",
|
||||
"dishwasher": "Dishwasher Device Simulation Configuration",
|
||||
"inverter": "Inverter Device Simulation Configuration",
|
||||
"measurement": "Measurement Configuration",
|
||||
"optimization": "General Optimization Configuration",
|
||||
"server": "Server Configuration",
|
||||
"elecprice": "Electricity Price Prediction Configuration",
|
||||
"load": "Load Prediction Configuration",
|
||||
"logging": "Logging Configuration",
|
||||
"prediction": "General Prediction Configuration",
|
||||
"pvforecast": "PV Forecast Configuration",
|
||||
"weather": "Weather Forecast Configuration",
|
||||
}
|
||||
documented_types: set[PydanticBaseModel] = set()
|
||||
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
|
||||
|
||||
# Static set of configuration names to include in a separate table
|
||||
GENERAL_CONFIGS = [
|
||||
"config_default_file_path",
|
||||
"config_file_path",
|
||||
"config_folder_path",
|
||||
"config_keys",
|
||||
"config_keys_read_only",
|
||||
"data_cache_path",
|
||||
"data_cache_subpath",
|
||||
"data_folder_path",
|
||||
"data_output_path",
|
||||
"data_output_subpath",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"package_root_path",
|
||||
"timezone",
|
||||
]
|
||||
global_config_dict: dict[str, Any] = dict()
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
configs (dict): Configuration values with keys and their descriptions.
|
||||
title (str): Title for the table.
|
||||
config (PydanticBaseModel): PydanticBaseModel configuration definition.
|
||||
prefix (str): Prefix for table entries.
|
||||
|
||||
Returns:
|
||||
str: The markdown table as a string.
|
||||
"""
|
||||
if not configs:
|
||||
return ""
|
||||
table = ""
|
||||
if toplevel:
|
||||
title = get_title(config)
|
||||
|
||||
heading_level = "###" if extra_config else "##"
|
||||
env_header = ""
|
||||
env_header_underline = ""
|
||||
env_width = ""
|
||||
if not extra_config:
|
||||
env_header = "| Environment Variable "
|
||||
env_header_underline = "| -------------------- "
|
||||
env_width = "20 "
|
||||
|
||||
table += f"{heading_level} {title}\n\n"
|
||||
|
||||
body = get_body(config)
|
||||
if body:
|
||||
table += body
|
||||
table += "\n\n"
|
||||
|
||||
table += (
|
||||
":::{table} "
|
||||
+ f"{'::'.join(toplevel_keys)}\n:widths: 10 {env_width}10 5 5 30\n:align: left\n\n"
|
||||
)
|
||||
table += f"| Name {env_header}| Type | Read-Only | Default | Description |\n"
|
||||
table += f"| ---- {env_header_underline}| ---- | --------- | ------- | ----------- |\n"
|
||||
|
||||
for field_name, field_info in list(config.model_fields.items()) + list(
|
||||
config.model_computed_fields.items()
|
||||
):
|
||||
regular_field = isinstance(field_info, FieldInfo)
|
||||
|
||||
config_name = field_name if extra_config else field_name.upper()
|
||||
field_type = field_info.annotation if regular_field else field_info.return_type
|
||||
default_value = get_default_value(field_info, regular_field)
|
||||
description = field_info.description if field_info.description else "-"
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
table = f"## {title}\n\n"
|
||||
table += ":::{table} " + f"{title}\n:widths: 10 10 5 5 30\n:align: left\n\n"
|
||||
table += "| Name | Type | Read-Only | Default | Description |\n"
|
||||
table += "| ---- | ---- | --------- | ------- | ----------- |\n"
|
||||
for name, config in sorted(configs.items()):
|
||||
type_name = config["type"]
|
||||
if type_name.startswith("typing."):
|
||||
type_name = type_name[len("typing.") :]
|
||||
table += f"| `{config['name']}` | `{type_name}` | `{config['read-only']}` | `{config['default']}` | {config['description']} |\n"
|
||||
table += ":::\n\n" # Add an empty line after the table
|
||||
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.
|
||||
|
||||
Returns:
|
||||
str: The Markdown representation of the configuration spec.
|
||||
"""
|
||||
configs = {}
|
||||
config_keys = config_eos.config_keys
|
||||
config_keys_read_only = config_eos.config_keys_read_only
|
||||
for config_key in config_keys:
|
||||
config = {}
|
||||
config["name"] = config_key
|
||||
config["value"] = getattr(config_eos, config_key)
|
||||
# Fix file path for general settings to not show local/test file path
|
||||
GeneralSettings._config_file_path = Path(
|
||||
"/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
|
||||
)
|
||||
GeneralSettings._config_folder_path = config_eos.general.config_file_path.parent
|
||||
|
||||
if config_key in config_keys_read_only:
|
||||
config["read-only"] = "ro"
|
||||
computed_field_info = config_eos.__pydantic_decorators__.computed_fields[
|
||||
config_key
|
||||
].info
|
||||
config["default"] = "N/A"
|
||||
config["description"] = computed_field_info.description
|
||||
config["type"] = str(computed_field_info.return_type)
|
||||
else:
|
||||
config["read-only"] = "rw"
|
||||
field_info = config_eos.model_fields[config_key]
|
||||
config["default"] = field_info.default
|
||||
config["description"] = field_info.description
|
||||
config["type"] = str(field_info.annotation)
|
||||
|
||||
configs[config_key] = config
|
||||
|
||||
# Generate markdown for the main table
|
||||
markdown = "# Configuration Table\n\n"
|
||||
|
||||
# Generate table for general configuration names
|
||||
general_configs = {k: v for k, v in configs.items() if k in GENERAL_CONFIGS}
|
||||
for k in general_configs.keys():
|
||||
del configs[k] # Remove general configs from the main configs dictionary
|
||||
markdown += generate_config_table_md(general_configs, "General Configuration Values")
|
||||
# Generate tables for each top level config
|
||||
for field_name, field_info in config_eos.model_fields.items():
|
||||
field_type = field_info.annotation
|
||||
markdown += generate_config_table_md(
|
||||
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
|
||||
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
|
||||
# Assure there is no double \n at end of file
|
||||
markdown = markdown.rstrip("\n")
|
||||
markdown += "\n"
|
||||
|
||||
@@ -145,9 +292,10 @@ def main():
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
config_eos = get_config()
|
||||
|
||||
try:
|
||||
config_md = generate_config_md()
|
||||
config_md = generate_config_md(config_eos)
|
||||
if args.output_file:
|
||||
# Write to file
|
||||
with open(args.output_file, "w", encoding="utf8") as f:
|
||||
@@ -158,7 +306,8 @@ def main():
|
||||
|
||||
except Exception as e:
|
||||
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__":
|
||||
|
@@ -37,6 +37,11 @@ def generate_openapi() -> dict:
|
||||
routes=app.routes,
|
||||
)
|
||||
|
||||
# Fix file path for general settings to not show local/test file path
|
||||
general = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["general"]["default"]
|
||||
general["config_file_path"] = "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
|
||||
general["config_folder_path"] = "/home/user/.config/net.akkudoktoreos.net"
|
||||
|
||||
return openapi_spec
|
||||
|
||||
|
||||
|
@@ -30,42 +30,63 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
|
||||
"""
|
||||
# Make a config
|
||||
settings = {
|
||||
# -- General --
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
# -- Predictions --
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
# PV Forecast
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
"pvforecast4_peakpower": None,
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
},
|
||||
],
|
||||
},
|
||||
# Weather Forecast
|
||||
"weather_provider": "ClearOutside",
|
||||
"weather": {
|
||||
"provider": "ClearOutside",
|
||||
},
|
||||
# Electricity Price Forecast
|
||||
"elecprice_provider": "ElecPriceAkkudoktor",
|
||||
"elecprice": {
|
||||
"provider": "ElecPriceAkkudoktor",
|
||||
},
|
||||
# Load Forecast
|
||||
"load_provider": "LoadAkkudoktor",
|
||||
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
|
||||
"load": {
|
||||
"provider": "LoadAkkudoktor",
|
||||
"provider_settings": {
|
||||
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
|
||||
},
|
||||
},
|
||||
# -- Simulations --
|
||||
}
|
||||
config_eos = get_config()
|
||||
@@ -129,20 +150,20 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
|
||||
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
|
||||
},
|
||||
"pv_akku": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 26400,
|
||||
"initial_soc_percentage": 15,
|
||||
"min_soc_percentage": 15,
|
||||
},
|
||||
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery_id": "battery1"},
|
||||
"eauto": {
|
||||
"device_id": "ev1",
|
||||
"min_soc_percentage": 50,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.95,
|
||||
"max_charge_power_w": 11040,
|
||||
"initial_soc_percentage": 5,
|
||||
},
|
||||
"inverter": {
|
||||
"max_power_wh": 10000,
|
||||
},
|
||||
"temperature_forecast": temperature_forecast,
|
||||
"start_solution": start_solution,
|
||||
}
|
||||
@@ -283,20 +304,20 @@ def prepare_optimization_parameters() -> OptimizationParameters:
|
||||
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
|
||||
},
|
||||
"pv_akku": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 26400,
|
||||
"initial_soc_percentage": 15,
|
||||
"min_soc_percentage": 15,
|
||||
},
|
||||
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery_id": "battery1"},
|
||||
"eauto": {
|
||||
"device_id": "ev1",
|
||||
"min_soc_percentage": 50,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.95,
|
||||
"max_charge_power_w": 11040,
|
||||
"initial_soc_percentage": 5,
|
||||
},
|
||||
"inverter": {
|
||||
"max_power_wh": 10000,
|
||||
},
|
||||
"temperature_forecast": temperature_forecast,
|
||||
"start_solution": start_solution,
|
||||
}
|
||||
@@ -330,7 +351,9 @@ def run_optimization(
|
||||
|
||||
# Initialize the optimization problem using the default configuration
|
||||
config_eos = get_config()
|
||||
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48})
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 48}, "optimization": {"hours": 48}}
|
||||
)
|
||||
opt_class = optimization_problem(verbose=verbose, fixed_seed=seed)
|
||||
|
||||
# Perform the optimisation based on the provided parameters and start hour
|
||||
|
@@ -16,32 +16,47 @@ prediction_eos = get_prediction()
|
||||
def config_pvforecast() -> dict:
|
||||
"""Configure settings for PV forecast."""
|
||||
settings = {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
"pvforecast4_peakpower": None,
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
return settings
|
||||
|
||||
@@ -49,10 +64,15 @@ def config_pvforecast() -> dict:
|
||||
def config_weather() -> dict:
|
||||
"""Configure settings for weather forecast."""
|
||||
settings = {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"weather": dict(),
|
||||
}
|
||||
return settings
|
||||
|
||||
@@ -60,10 +80,15 @@ def config_weather() -> dict:
|
||||
def config_elecprice() -> dict:
|
||||
"""Configure settings for electricity price forecast."""
|
||||
settings = {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"elecprice": dict(),
|
||||
}
|
||||
return settings
|
||||
|
||||
@@ -71,10 +96,14 @@ def config_elecprice() -> dict:
|
||||
def config_load() -> dict:
|
||||
"""Configure settings for load forecast."""
|
||||
settings = {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
}
|
||||
return settings
|
||||
|
||||
@@ -96,17 +125,17 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
|
||||
print(f"\nProvider ID: {provider_id}")
|
||||
if provider_id in ("PVForecastAkkudoktor",):
|
||||
settings = config_pvforecast()
|
||||
settings["pvforecast_provider"] = provider_id
|
||||
settings["pvforecast"]["provider"] = provider_id
|
||||
elif provider_id in ("BrightSky", "ClearOutside"):
|
||||
settings = config_weather()
|
||||
settings["weather_provider"] = provider_id
|
||||
settings["weather"]["provider"] = provider_id
|
||||
elif provider_id in ("ElecPriceAkkudoktor",):
|
||||
settings = config_elecprice()
|
||||
settings["elecprice_provider"] = provider_id
|
||||
settings["elecprice"]["provider"] = provider_id
|
||||
elif provider_id in ("LoadAkkudoktor",):
|
||||
settings = config_elecprice()
|
||||
settings["loadakkudoktor_year_energy"] = 1000
|
||||
settings["load_provider"] = provider_id
|
||||
settings["load"]["loadakkudoktor_year_energy"] = 1000
|
||||
settings["load"]["provider"] = provider_id
|
||||
else:
|
||||
raise ValueError(f"Unknown provider '{provider_id}'.")
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
|
@@ -12,30 +12,35 @@ Key features:
|
||||
import os
|
||||
import shutil
|
||||
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 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
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
from akkudoktoreos.core.decorators import classproperty
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
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.optimization.optimization import OptimizationCommonSettings
|
||||
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||
from akkudoktoreos.prediction.load import LoadCommonSettings
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import PredictionCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||
from akkudoktoreos.prediction.weather import WeatherCommonSettings
|
||||
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
||||
from akkudoktoreos.server.server import ServerCommonSettings
|
||||
from akkudoktoreos.utils.datetimeutil import to_timezone
|
||||
from akkudoktoreos.utils.utils import UtilsCommonSettings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -59,61 +64,137 @@ def get_absolute_path(
|
||||
return None
|
||||
|
||||
|
||||
class ConfigCommonSettings(SettingsBaseModel):
|
||||
"""Settings for common configuration."""
|
||||
class GeneralSettings(SettingsBaseModel):
|
||||
"""Settings for common configuration.
|
||||
|
||||
General configuration to set directories of cache and output files and system location (latitude
|
||||
and longitude).
|
||||
Validators ensure each parameter is within a specified range. A computed property, `timezone`,
|
||||
determines the time zone based on latitude and longitude.
|
||||
|
||||
Attributes:
|
||||
latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.
|
||||
longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.
|
||||
|
||||
Properties:
|
||||
timezone (Optional[str]): Computed time zone string based on the specified latitude
|
||||
and longitude.
|
||||
|
||||
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(
|
||||
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(
|
||||
"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(
|
||||
"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_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def timezone(self) -> Optional[str]:
|
||||
"""Compute timezone based on latitude and longitude."""
|
||||
if self.latitude and self.longitude:
|
||||
return to_timezone(location=(self.latitude, self.longitude), as_string=True)
|
||||
return None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def data_output_path(self) -> Optional[Path]:
|
||||
"""Compute data_output_path based on data_folder_path."""
|
||||
return get_absolute_path(self.data_folder_path, self.data_output_subpath)
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def data_cache_path(self) -> Optional[Path]:
|
||||
"""Compute data_cache_path based on data_folder_path."""
|
||||
return get_absolute_path(self.data_folder_path, self.data_cache_subpath)
|
||||
|
||||
@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(
|
||||
ConfigCommonSettings,
|
||||
LoggingCommonSettings,
|
||||
DevicesCommonSettings,
|
||||
MeasurementCommonSettings,
|
||||
OptimizationCommonSettings,
|
||||
PredictionCommonSettings,
|
||||
ElecPriceCommonSettings,
|
||||
ElecPriceImportCommonSettings,
|
||||
LoadCommonSettings,
|
||||
LoadAkkudoktorCommonSettings,
|
||||
LoadImportCommonSettings,
|
||||
PVForecastCommonSettings,
|
||||
PVForecastImportCommonSettings,
|
||||
WeatherCommonSettings,
|
||||
WeatherImportCommonSettings,
|
||||
ServerCommonSettings,
|
||||
UtilsCommonSettings,
|
||||
):
|
||||
"""Settings for all EOS."""
|
||||
|
||||
pass
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_file_path(self) -> Optional[Path]:
|
||||
"""Path to EOS configuration file."""
|
||||
return self._config_file_path
|
||||
|
||||
|
||||
class 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.
|
||||
|
||||
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.
|
||||
|
||||
Attributes:
|
||||
_settings (ClassVar[SettingsEOS]): Holds application-wide settings.
|
||||
_file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file.
|
||||
config_folder_path (Optional[Path]): Path to the configuration directory.
|
||||
config_file_path (Optional[Path]): Path to the configuration file.
|
||||
|
||||
@@ -155,7 +234,7 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
|
||||
To initialize and access configuration attributes (only one instance is created):
|
||||
```python
|
||||
config_eos = ConfigEOS() # Always returns the same instance
|
||||
print(config_eos.prediction_hours) # Access a setting from the loaded configuration
|
||||
print(config_eos.prediction.hours) # Access a setting from the loaded configuration
|
||||
```
|
||||
|
||||
"""
|
||||
@@ -167,111 +246,111 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
|
||||
ENCODING: ClassVar[str] = "UTF-8"
|
||||
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
|
||||
|
||||
_settings: ClassVar[Optional[SettingsEOS]] = None
|
||||
_file_settings: ClassVar[Optional[SettingsEOS]] = None
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: Type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||
"""Customizes the order and handling of settings sources for a Pydantic BaseSettings subclass.
|
||||
|
||||
_config_folder_path: Optional[Path] = None
|
||||
_config_file_path: Optional[Path] = None
|
||||
This method determines the sources for application configuration settings, including
|
||||
environment variables, dotenv files and JSON configuration files.
|
||||
It ensures that a default configuration file exists and creates one if necessary.
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_folder_path(self) -> Optional[Path]:
|
||||
"""Path to EOS configuration directory."""
|
||||
return self._config_folder_path
|
||||
Args:
|
||||
settings_cls (Type[BaseSettings]): The Pydantic BaseSettings class for which sources are customized.
|
||||
init_settings (PydanticBaseSettingsSource): The initial settings source, typically passed at runtime.
|
||||
env_settings (PydanticBaseSettingsSource): Settings sourced from environment variables.
|
||||
dotenv_settings (PydanticBaseSettingsSource): Settings sourced from a dotenv file.
|
||||
file_secret_settings (PydanticBaseSettingsSource): Unused (needed for parent class interface).
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_file_path(self) -> Optional[Path]:
|
||||
"""Path to EOS configuration file."""
|
||||
return self._config_file_path
|
||||
Returns:
|
||||
tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_default_file_path(self) -> Path:
|
||||
Behavior:
|
||||
1. Checks for the existence of a JSON configuration file in the expected location.
|
||||
2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a
|
||||
default configuration file to the location. If the copy fails, uses the default configuration file directly.
|
||||
3. Creates a `JsonConfigSettingsSource` for both the configuration file and the default configuration file.
|
||||
4. Updates class attributes `GeneralSettings._config_folder_path` and
|
||||
`GeneralSettings._config_file_path` to reflect the determined paths.
|
||||
5. Returns a tuple containing all provided and newly created settings sources in the desired order.
|
||||
|
||||
Notes:
|
||||
- This method logs a warning if the default configuration file cannot be copied.
|
||||
- It ensures that a fallback to the default configuration file is always possible.
|
||||
"""
|
||||
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."""
|
||||
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]
|
||||
@property
|
||||
def package_root_path(self) -> Path:
|
||||
@classproperty
|
||||
def package_root_path(cls) -> Path:
|
||||
"""Compute the package root path."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_keys(self) -> List[str]:
|
||||
"""Returns the keys of all fields in the configuration."""
|
||||
key_list = []
|
||||
key_list.extend(list(self.model_fields.keys()))
|
||||
key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys()))
|
||||
return key_list
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def config_keys_read_only(self) -> List[str]:
|
||||
"""Returns the keys of all read only fields in the configuration."""
|
||||
key_list = []
|
||||
key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys()))
|
||||
return key_list
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initializes the singleton ConfigEOS instance.
|
||||
|
||||
Configuration data is loaded from a configuration file or a default one is created if none
|
||||
exists.
|
||||
"""
|
||||
super().__init__()
|
||||
self.from_config_file()
|
||||
self.update()
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__(*args, **kwargs)
|
||||
self._create_initial_config_file()
|
||||
self._update_data_folder_path()
|
||||
|
||||
@property
|
||||
def settings(self) -> Optional[SettingsEOS]:
|
||||
"""Returns global settings for EOS.
|
||||
def _setup(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Re-initialize global settings."""
|
||||
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.
|
||||
|
||||
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:
|
||||
def merge_settings(self, settings: SettingsEOS) -> None:
|
||||
"""Merges the provided settings into the global settings for EOS, with optional overwrite.
|
||||
|
||||
Args:
|
||||
settings (SettingsEOS): The settings to apply globally.
|
||||
force (Optional[bool]): If True, overwrites the existing settings completely.
|
||||
If False, the new settings are merged to the existing ones with priority for
|
||||
the new ones. Defaults to False.
|
||||
|
||||
Raises:
|
||||
ValueError: If settings are already set and `force` is not True or
|
||||
if the `settings` is not a `SettingsEOS` instance.
|
||||
ValueError: If the `settings` is not a `SettingsEOS` instance.
|
||||
"""
|
||||
if not isinstance(settings, SettingsEOS):
|
||||
raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.")
|
||||
|
||||
if ConfigEOS._settings is None or force:
|
||||
ConfigEOS._settings = settings
|
||||
else:
|
||||
self._merge_and_update_settings(settings)
|
||||
|
||||
# Update configuration after merging
|
||||
self.update()
|
||||
self.merge_settings_from_dict(settings.model_dump(exclude_none=True, exclude_unset=True))
|
||||
|
||||
def merge_settings_from_dict(self, data: dict) -> None:
|
||||
"""Merges the provided dictionary data into the current instance.
|
||||
@@ -289,141 +368,83 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
|
||||
|
||||
Example:
|
||||
>>> 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)
|
||||
"""
|
||||
# Create new settings instance with reset optional fields and merged data
|
||||
settings = SettingsEOS.from_dict(data)
|
||||
self.merge_settings(settings)
|
||||
self._setup(**merge_models(self, data))
|
||||
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
"""Updates path to the data directory."""
|
||||
# From Settings
|
||||
if self.settings and (data_dir := self.settings.data_folder_path):
|
||||
if data_dir := self.general.data_folder_path:
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.data_folder_path = data_dir
|
||||
self.general.data_folder_path = data_dir
|
||||
return
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not setup data dir: {e}")
|
||||
# From EOS_DIR env
|
||||
env_dir = os.getenv(self.EOS_DIR)
|
||||
if env_dir is not None:
|
||||
if env_dir := os.getenv(self.EOS_DIR):
|
||||
try:
|
||||
data_dir = Path(env_dir).resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.data_folder_path = data_dir
|
||||
self.general.data_folder_path = data_dir
|
||||
return
|
||||
except:
|
||||
pass
|
||||
# From configuration file
|
||||
if self._file_settings and (data_dir := self._file_settings.data_folder_path):
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.data_folder_path = data_dir
|
||||
return
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not setup data dir: {e}")
|
||||
# From platform specific default path
|
||||
try:
|
||||
data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
|
||||
if data_dir is not None:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.data_folder_path = data_dir
|
||||
self.general.data_folder_path = data_dir
|
||||
return
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not setup data dir: {e}")
|
||||
# Current working directory
|
||||
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.
|
||||
|
||||
Returns:
|
||||
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there
|
||||
"""
|
||||
config_dirs = []
|
||||
env_base_dir = os.getenv(self.EOS_DIR)
|
||||
env_config_dir = os.getenv(self.EOS_CONFIG_DIR)
|
||||
env_base_dir = os.getenv(cls.EOS_DIR)
|
||||
env_config_dir = os.getenv(cls.EOS_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:
|
||||
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())
|
||||
for cdir in config_dirs:
|
||||
cfile = cdir.joinpath(self.CONFIG_FILE_NAME)
|
||||
cfile = cdir.joinpath(cls.CONFIG_FILE_NAME)
|
||||
if cfile.exists():
|
||||
logger.debug(f"Found config file: '{cfile}'")
|
||||
return cfile, True
|
||||
return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False
|
||||
|
||||
def settings_from_config_file(self) -> tuple[SettingsEOS, Path]:
|
||||
"""Load settings from the configuration file.
|
||||
|
||||
If the config file does not exist, it will be created.
|
||||
|
||||
Returns:
|
||||
tuple of settings and path
|
||||
settings (SettingsEOS): The settings defined by the EOS configuration file.
|
||||
path (pathlib.Path): The path of the configuration file.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration file is invalid or incomplete.
|
||||
"""
|
||||
config_file, exists = self._get_config_file_path()
|
||||
config_dir = config_file.parent
|
||||
|
||||
# Create config directory and copy default config if file does not exist
|
||||
if not exists:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(self.config_default_file_path, config_file)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not copy default config: {exc}. Using default config...")
|
||||
config_file = self.config_default_file_path
|
||||
config_dir = config_file.parent
|
||||
|
||||
# Load and validate the configuration file
|
||||
with config_file.open("r", encoding=self.ENCODING) as f_in:
|
||||
try:
|
||||
json_str = f_in.read()
|
||||
settings = SettingsEOS.model_validate_json(json_str)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Configuration '{config_file}' is incomplete or not valid: {exc}")
|
||||
|
||||
return settings, config_file
|
||||
|
||||
def from_config_file(self) -> tuple[SettingsEOS, Path]:
|
||||
"""Load the configuration file settings for EOS.
|
||||
|
||||
Returns:
|
||||
tuple of settings and path
|
||||
settings (SettingsEOS): The settings defined by the EOS configuration file.
|
||||
path (pathlib.Path): The path of the configuration file.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration file is invalid or incomplete.
|
||||
"""
|
||||
# Load settings from config file
|
||||
ConfigEOS._file_settings, config_file = self.settings_from_config_file()
|
||||
|
||||
# Update configuration in memory
|
||||
self.update()
|
||||
|
||||
# Everything worked, remember the values
|
||||
self._config_folder_path = config_file.parent
|
||||
self._config_file_path = config_file
|
||||
|
||||
return ConfigEOS._file_settings, config_file
|
||||
return config_dirs[0].joinpath(cls.CONFIG_FILE_NAME), False
|
||||
|
||||
def to_config_file(self) -> None:
|
||||
"""Saves the current configuration to the configuration file.
|
||||
@@ -433,77 +454,24 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
|
||||
Raises:
|
||||
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.")
|
||||
with self.config_file_path.open("w", encoding=self.ENCODING) as f_out:
|
||||
try:
|
||||
json_str = super().to_json()
|
||||
# Write to file
|
||||
f_out.write(json_str)
|
||||
# Also remember as actual settings
|
||||
ConfigEOS._file_settings = SettingsEOS.model_validate_json(json_str)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Could not update '{self.config_file_path}': {exc}")
|
||||
|
||||
def _config_value(self, key: str) -> Any:
|
||||
"""Retrieves the configuration value for a specific key, following a priority order.
|
||||
|
||||
Values are fetched in the following order:
|
||||
1. Settings.
|
||||
2. Environment variables.
|
||||
3. EOS configuration file.
|
||||
4. Current configuration.
|
||||
5. Field default constants.
|
||||
|
||||
Args:
|
||||
key (str): The configuration key to retrieve.
|
||||
|
||||
Returns:
|
||||
Any: The configuration value, or None if not found.
|
||||
"""
|
||||
# Settings
|
||||
if ConfigEOS._settings:
|
||||
if (value := getattr(self.settings, key, None)) is not None:
|
||||
return value
|
||||
|
||||
# Environment variables
|
||||
if (value := os.getenv(key)) is not None:
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
# EOS configuration file.
|
||||
if self._file_settings:
|
||||
if (value := getattr(self._file_settings, key, None)) is not None:
|
||||
return value
|
||||
|
||||
# Current configuration - key is valid as called by update().
|
||||
if (value := getattr(self, key, None)) is not None:
|
||||
return value
|
||||
|
||||
# Field default constants
|
||||
if (value := ConfigEOS.model_fields[key].default) is not None:
|
||||
return value
|
||||
|
||||
logger.debug(f"Value for configuration key '{key}' not found or is {value}")
|
||||
return None
|
||||
with self.general.config_file_path.open("w", encoding=self.ENCODING) as f_out:
|
||||
json_str = super().model_dump_json()
|
||||
f_out.write(json_str)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Updates all configuration fields.
|
||||
|
||||
This method updates all configuration fields using the following order for value retrieval:
|
||||
1. Settings.
|
||||
1. Current settings.
|
||||
2. Environment variables.
|
||||
3. EOS configuration file.
|
||||
4. Current configuration.
|
||||
5. Field default constants.
|
||||
4. Field default constants.
|
||||
|
||||
The first non None value in priority order is taken.
|
||||
"""
|
||||
self._update_data_folder_path()
|
||||
for key in self.model_fields:
|
||||
setattr(self, key, self._config_value(key))
|
||||
self._setup(**self.model_dump())
|
||||
|
||||
|
||||
def get_config() -> ConfigEOS:
|
||||
|
@@ -4,10 +4,6 @@ from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
|
||||
|
||||
class SettingsBaseModel(PydanticBaseModel):
|
||||
"""Base model class for all settings configurations.
|
||||
|
||||
Note:
|
||||
Settings property names shall be disjunctive to all existing settings' property names.
|
||||
"""
|
||||
"""Base model class for all settings configurations."""
|
||||
|
||||
pass
|
||||
|
@@ -265,6 +265,12 @@ class SingletonMixin:
|
||||
class MySingletonModel(SingletonMixin, PydanticBaseModel):
|
||||
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")
|
||||
instance2 = MySingletonModel(name="Instance 2")
|
||||
|
||||
|
@@ -1110,7 +1110,7 @@ class DataProvider(SingletonMixin, DataSequence):
|
||||
|
||||
To be implemented by derived classes.
|
||||
"""
|
||||
return self.provider_id() == self.config.abstract_provider
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
@@ -1121,6 +1121,11 @@ class DataProvider(SingletonMixin, DataSequence):
|
||||
"""
|
||||
pass
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def update_data(
|
||||
self,
|
||||
force_enable: Optional[bool] = False,
|
||||
@@ -1595,6 +1600,11 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
|
||||
)
|
||||
return list(key_set)
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __getitem__(self, key: str) -> pd.Series:
|
||||
"""Retrieve a Pandas Series for a specified key from the data in each DataProvider.
|
||||
|
||||
|
48
src/akkudoktoreos/core/decorators.py
Normal file
48
src/akkudoktoreos/core/decorators.py
Normal 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)
|
@@ -1,4 +1,4 @@
|
||||
from typing import Any, ClassVar, Dict, Optional, Union
|
||||
from typing import Any, ClassVar, Optional
|
||||
|
||||
import numpy as np
|
||||
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")
|
||||
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_parameters(
|
||||
self,
|
||||
parameters: EnergieManagementSystemParameters,
|
||||
@@ -186,19 +191,19 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
|
||||
len(self.load_energy_array), parameters.einspeiseverguetung_euro_pro_wh, float
|
||||
)
|
||||
)
|
||||
if inverter is not None:
|
||||
if inverter:
|
||||
self.battery = inverter.battery
|
||||
else:
|
||||
self.battery = None
|
||||
self.ev = ev
|
||||
self.home_appliance = home_appliance
|
||||
self.inverter = inverter
|
||||
self.ac_charge_hours = np.full(self.config.prediction_hours, 0.0)
|
||||
self.dc_charge_hours = np.full(self.config.prediction_hours, 1.0)
|
||||
self.ev_charge_hours = np.full(self.config.prediction_hours, 0.0)
|
||||
self.ac_charge_hours = np.full(self.config.prediction.hours, 0.0)
|
||||
self.dc_charge_hours = np.full(self.config.prediction.hours, 1.0)
|
||||
self.ev_charge_hours = np.full(self.config.prediction.hours, 0.0)
|
||||
|
||||
def set_akku_discharge_hours(self, ds: np.ndarray) -> None:
|
||||
if self.battery is not None:
|
||||
if self.battery:
|
||||
self.battery.set_discharge_per_hour(ds)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
def reset(self) -> None:
|
||||
@@ -246,11 +251,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
|
||||
error_msg = "Start datetime unknown."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
if self.config.prediction_hours is None:
|
||||
if self.config.prediction.hours is None:
|
||||
error_msg = "Prediction hours unknown."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
if self.config.optimisation_hours is None:
|
||||
if self.config.prediction.optimisation_hours is None:
|
||||
error_msg = "Optimisation hours unknown."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
@@ -276,53 +281,50 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda
|
||||
return self.simulate(start_hour)
|
||||
|
||||
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!
|
||||
last_wh_pro_stunde integral of last hour (end state)
|
||||
last_wh_pro_stunde integral of last hour (end state)
|
||||
"""
|
||||
# Check for simulation integrity
|
||||
missing_data = []
|
||||
|
||||
if self.load_energy_array is None:
|
||||
missing_data.append("Load Curve")
|
||||
if self.pv_prediction_wh is None:
|
||||
missing_data.append("PV Forecast")
|
||||
if self.elect_price_hourly is None:
|
||||
missing_data.append("Electricity Price")
|
||||
if self.ev_charge_hours is None:
|
||||
missing_data.append("EV Charge Hours")
|
||||
if self.ac_charge_hours 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")
|
||||
required_attrs = [
|
||||
"load_energy_array",
|
||||
"pv_prediction_wh",
|
||||
"elect_price_hourly",
|
||||
"ev_charge_hours",
|
||||
"ac_charge_hours",
|
||||
"dc_charge_hours",
|
||||
"elect_revenue_per_hour_arr",
|
||||
]
|
||||
missing_data = [
|
||||
attr.replace("_", " ").title() for attr in required_attrs if getattr(self, attr) is None
|
||||
]
|
||||
|
||||
if missing_data:
|
||||
error_msg = "Mandatory data missing - " + ", ".join(missing_data)
|
||||
logger.error(error_msg)
|
||||
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
|
||||
logger.error("Mandatory data missing - %s", ", ".join(missing_data))
|
||||
raise ValueError(f"Mandatory data missing: {', '.join(missing_data)}")
|
||||
|
||||
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 (
|
||||
len(load_energy_array) == len(self.pv_prediction_wh) == len(self.elect_price_hourly)
|
||||
):
|
||||
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)}"
|
||||
# Fetch objects
|
||||
battery = self.battery
|
||||
assert battery # to please mypy
|
||||
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)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Optimized total hours calculation
|
||||
end_hour = len(load_energy_array)
|
||||
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)
|
||||
costs_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)
|
||||
losses_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)
|
||||
|
||||
# Set initial state
|
||||
if self.battery:
|
||||
soc_per_hour[0] = self.battery.current_soc_percentage()
|
||||
if self.ev:
|
||||
soc_ev_per_hour[0] = self.ev.current_soc_percentage()
|
||||
soc_per_hour[0] = battery.current_soc_percentage()
|
||||
if ev:
|
||||
soc_ev_per_hour[0] = ev.current_soc_percentage()
|
||||
|
||||
for hour in range(start_hour, end_hour):
|
||||
hour_since_now = hour - start_hour
|
||||
hour_idx = hour - start_hour
|
||||
|
||||
# save begin states
|
||||
if self.battery:
|
||||
soc_per_hour[hour_since_now] = self.battery.current_soc_percentage()
|
||||
else:
|
||||
soc_per_hour[hour_since_now] = 0.0
|
||||
if self.ev:
|
||||
soc_ev_per_hour[hour_since_now] = self.ev.current_soc_percentage()
|
||||
soc_per_hour[hour_idx] = battery.current_soc_percentage()
|
||||
|
||||
if ev:
|
||||
soc_ev_per_hour[hour_idx] = ev.current_soc_percentage()
|
||||
|
||||
# Accumulate loads and PV generation
|
||||
consumption = self.load_energy_array[hour]
|
||||
losses_wh_per_hour[hour_since_now] = 0.0
|
||||
consumption = load_energy_array[hour]
|
||||
losses_wh_per_hour[hour_idx] = 0.0
|
||||
|
||||
# Home appliances
|
||||
if self.home_appliance:
|
||||
ha_load = self.home_appliance.get_load_for_hour(hour)
|
||||
if home_appliance:
|
||||
ha_load = home_appliance.get_load_for_hour(hour)
|
||||
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
|
||||
if self.ev:
|
||||
if self.ev_charge_hours[hour] > 0:
|
||||
loaded_energy_ev, verluste_eauto = self.ev.charge_energy(
|
||||
None, hour, relative_power=self.ev_charge_hours[hour]
|
||||
)
|
||||
consumption += loaded_energy_ev
|
||||
losses_wh_per_hour[hour_since_now] += verluste_eauto
|
||||
if ev and ev_charge_hours[hour] > 0:
|
||||
loaded_energy_ev, verluste_eauto = ev.charge_energy(
|
||||
None, hour, relative_power=ev_charge_hours[hour]
|
||||
)
|
||||
consumption += loaded_energy_ev
|
||||
losses_wh_per_hour[hour_idx] += verluste_eauto
|
||||
|
||||
# Process inverter logic
|
||||
energy_feedin_grid_actual, energy_consumption_grid_actual, losses, eigenverbrauch = (
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
energy_feedin_grid_actual = energy_consumption_grid_actual = losses = eigenverbrauch = (
|
||||
0.0
|
||||
)
|
||||
if self.battery:
|
||||
self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour)
|
||||
if self.inverter:
|
||||
energy_produced = self.pv_prediction_wh[hour]
|
||||
|
||||
hour_ac_charge = ac_charge_hours[hour]
|
||||
hour_dc_charge = dc_charge_hours[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_consumption_grid_actual,
|
||||
losses,
|
||||
eigenverbrauch,
|
||||
) = self.inverter.process_energy(energy_produced, consumption, hour)
|
||||
) = inverter.process_energy(energy_produced, 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)
|
||||
battery_charged_energy_actual, battery_losses_actual = self.battery.charge_energy(
|
||||
None, hour, relative_power=self.ac_charge_hours[hour]
|
||||
if hour_ac_charge > 0.0:
|
||||
battery.set_charge_allowed_for_hour(1, hour)
|
||||
battery_charged_energy_actual, battery_losses_actual = battery.charge_energy(
|
||||
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
|
||||
consumption_energy_per_hour[hour_since_now] = energy_consumption_grid_actual
|
||||
losses_wh_per_hour[hour_since_now] += losses
|
||||
loads_energy_per_hour[hour_since_now] = consumption
|
||||
electricity_price_per_hour[hour_since_now] = self.elect_price_hourly[hour]
|
||||
total_battery_energy = battery_charged_energy_actual + battery_losses_actual
|
||||
consumption += total_battery_energy
|
||||
energy_consumption_grid_actual += total_battery_energy
|
||||
losses_wh_per_hour[hour_idx] += battery_losses_actual
|
||||
|
||||
# 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
|
||||
costs_per_hour[hour_since_now] = (
|
||||
energy_consumption_grid_actual * self.elect_price_hourly[hour]
|
||||
)
|
||||
revenue_per_hour[hour_since_now] = (
|
||||
energy_feedin_grid_actual * self.elect_revenue_per_hour_arr[hour]
|
||||
)
|
||||
costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price
|
||||
revenue_per_hour[hour_idx] = energy_feedin_grid_actual * hourly_energy_revenue
|
||||
|
||||
# Total cost and return
|
||||
gesamtkosten_euro = np.nansum(costs_per_hour) - np.nansum(revenue_per_hour)
|
||||
total_cost = np.nansum(costs_per_hour)
|
||||
total_losses = np.nansum(losses_wh_per_hour)
|
||||
total_revenue = np.nansum(revenue_per_hour)
|
||||
|
||||
# Prepare output dictionary
|
||||
out: Dict[str, Union[np.ndarray, float]] = {
|
||||
return {
|
||||
"Last_Wh_pro_Stunde": loads_energy_per_hour,
|
||||
"Netzeinspeisung_Wh_pro_Stunde": feedin_energy_per_hour,
|
||||
"Netzbezug_Wh_pro_Stunde": consumption_energy_per_hour,
|
||||
"Kosten_Euro_pro_Stunde": costs_per_hour,
|
||||
"akku_soc_pro_stunde": soc_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,
|
||||
"Gesamteinnahmen_Euro": np.nansum(revenue_per_hour),
|
||||
"Gesamtkosten_Euro": np.nansum(costs_per_hour),
|
||||
"Gesamteinnahmen_Euro": total_revenue,
|
||||
"Gesamtkosten_Euro": total_cost,
|
||||
"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,
|
||||
"Electricity_price": electricity_price_per_hour,
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# Initialize the Energy Management System, it is a singleton.
|
||||
ems = EnergieManagementSystem()
|
||||
|
@@ -4,7 +4,6 @@ Kept in an extra module to avoid cyclic dependencies on package import.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
@@ -14,21 +13,20 @@ from akkudoktoreos.core.logabc import logging_str_to_level
|
||||
|
||||
|
||||
class LoggingCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for logging."""
|
||||
"""Logging Configuration."""
|
||||
|
||||
logging_level_default: Optional[str] = Field(
|
||||
default=None, description="EOS default logging level."
|
||||
level: Optional[str] = Field(
|
||||
default=None,
|
||||
description="EOS default logging level.",
|
||||
examples=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"],
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("logging_level_default", mode="after")
|
||||
@field_validator("level", mode="after")
|
||||
@classmethod
|
||||
def set_default_logging_level(cls, value: Optional[str]) -> Optional[str]:
|
||||
if isinstance(value, str) and value.upper() == "NONE":
|
||||
value = None
|
||||
if value is None and (env_level := os.getenv("EOS_LOGGING_LEVEL")) is not None:
|
||||
# Take default logging level from special environment variable
|
||||
value = env_level
|
||||
if value is None:
|
||||
return None
|
||||
level = logging_str_to_level(value)
|
||||
@@ -38,7 +36,7 @@ class LoggingCommonSettings(SettingsBaseModel):
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def logging_level_root(self) -> str:
|
||||
def root_level(self) -> str:
|
||||
"""Root logger logging level."""
|
||||
level = logging.getLogger().getEffectiveLevel()
|
||||
level_name = logging.getLevelName(level)
|
||||
|
@@ -14,6 +14,7 @@ Key Features:
|
||||
|
||||
import json
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -35,6 +36,21 @@ from pydantic import (
|
||||
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]):
|
||||
"""Custom type adapter for Pendulum DateTime fields."""
|
||||
|
||||
@@ -113,9 +129,16 @@ class PydanticBaseModel(BaseModel):
|
||||
return value
|
||||
|
||||
# Override Pydantic’s 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."""
|
||||
result = super().model_dump(*args, **kwargs)
|
||||
|
||||
if not include_computed_fields:
|
||||
for computed_field_name in self.model_computed_fields:
|
||||
result.pop(computed_field_name, None)
|
||||
|
||||
for key, value in result.items():
|
||||
if isinstance(value, pendulum.DateTime):
|
||||
result[key] = PydanticTypeAdapterDateTime.serialize(value)
|
||||
@@ -170,6 +193,10 @@ class PydanticBaseModel(BaseModel):
|
||||
"""
|
||||
return cls.model_validate(data)
|
||||
|
||||
def model_dump_json(self, *args: Any, indent: Optional[int] = None, **kwargs: Any) -> str:
|
||||
data = self.model_dump(*args, **kwargs)
|
||||
return json.dumps(data, indent=indent, default=str)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convert the PydanticBaseModel instance to a JSON string.
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -1,11 +1,14 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
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.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
||||
from akkudoktoreos.devices.devicesabc import (
|
||||
DeviceBase,
|
||||
DeviceOptimizeResult,
|
||||
DeviceParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
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:
|
||||
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):
|
||||
"""Base class for battery parameters with fields for capacity, efficiency, and state of charge."""
|
||||
def discharging_efficiency_field(default_value: float) -> float:
|
||||
return Field(
|
||||
default=default_value,
|
||||
gt=0,
|
||||
le=1,
|
||||
description="A float representing the discharge efficiency of the battery.",
|
||||
)
|
||||
|
||||
|
||||
class BaseBatteryParameters(DeviceParameters):
|
||||
"""Battery Device Simulation Configuration."""
|
||||
|
||||
device_id: str = Field(description="ID of battery", examples=["battery1"])
|
||||
capacity_wh: int = Field(
|
||||
gt=0, description="An integer representing the capacity of the battery in watt-hours."
|
||||
gt=0,
|
||||
description="An integer representing the capacity of the battery in watt-hours.",
|
||||
examples=[8000],
|
||||
)
|
||||
charging_efficiency: float = Field(
|
||||
default=0.88,
|
||||
@@ -37,12 +52,7 @@ class BaseBatteryParameters(ParametersBaseModel):
|
||||
le=1,
|
||||
description="A float representing the charging efficiency of the battery.",
|
||||
)
|
||||
discharging_efficiency: float = Field(
|
||||
default=0.88,
|
||||
gt=0,
|
||||
le=1,
|
||||
description="A float representing the discharge efficiency of the battery.",
|
||||
)
|
||||
discharging_efficiency: float = discharging_efficiency_field(0.88)
|
||||
max_charge_power_w: Optional[float] = max_charging_power_field()
|
||||
initial_soc_percentage: int = initial_soc_percentage_field(
|
||||
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
|
||||
@@ -52,6 +62,7 @@ class BaseBatteryParameters(ParametersBaseModel):
|
||||
ge=0,
|
||||
le=100,
|
||||
description="An integer representing the minimum state of charge (SOC) of the battery in percentage.",
|
||||
examples=[10],
|
||||
)
|
||||
max_soc_percentage: int = Field(
|
||||
default=100,
|
||||
@@ -66,17 +77,19 @@ class SolarPanelBatteryParameters(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(
|
||||
"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."""
|
||||
|
||||
device_id: str = Field(description="ID of electric vehicle", examples=["ev1"])
|
||||
charge_array: list[float] = Field(
|
||||
description="Hourly charging status (0 for no charging, 1 for charging)."
|
||||
)
|
||||
@@ -84,7 +97,6 @@ class ElectricVehicleResult(BaseModel):
|
||||
description="Hourly discharging status (0 for no discharging, 1 for discharging)."
|
||||
)
|
||||
discharging_efficiency: float = Field(description="The discharge efficiency as a float..")
|
||||
hours: int = Field(description="Number of hours in the simulation.")
|
||||
capacity_wh: int = Field(description="Capacity of the EV’s battery in watt-hours.")
|
||||
charging_efficiency: float = Field(description="Charging efficiency as a float..")
|
||||
max_charge_power_w: int = Field(description="Maximum charging power in watts.")
|
||||
@@ -103,81 +115,30 @@ class ElectricVehicleResult(BaseModel):
|
||||
class Battery(DeviceBase):
|
||||
"""Represents a battery device with methods to simulate energy charging and discharging."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parameters: Optional[BaseBatteryParameters] = None,
|
||||
hours: Optional[int] = 24,
|
||||
provider_id: Optional[str] = None,
|
||||
):
|
||||
# Initialize configuration and parameters
|
||||
self.provider_id = provider_id
|
||||
self.prefix = "<invalid>"
|
||||
if self.provider_id == "GenericBattery":
|
||||
self.prefix = "battery"
|
||||
elif self.provider_id == "GenericBEV":
|
||||
self.prefix = "bev"
|
||||
def __init__(self, parameters: Optional[BaseBatteryParameters] = None):
|
||||
self.parameters: Optional[BaseBatteryParameters] = None
|
||||
super().__init__(parameters)
|
||||
|
||||
self.parameters = parameters
|
||||
if hours is None:
|
||||
self.hours = self.total_hours # TODO where does that come from?
|
||||
else:
|
||||
self.hours = hours
|
||||
|
||||
self.initialised = False
|
||||
|
||||
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
|
||||
if self.parameters is not None:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
def _setup(self) -> None:
|
||||
"""Sets up the battery parameters based on configuration or provided parameters."""
|
||||
if self.initialised:
|
||||
return
|
||||
assert self.parameters is not None
|
||||
self.capacity_wh = self.parameters.capacity_wh
|
||||
self.initial_soc_percentage = self.parameters.initial_soc_percentage
|
||||
self.charging_efficiency = self.parameters.charging_efficiency
|
||||
self.discharging_efficiency = self.parameters.discharging_efficiency
|
||||
|
||||
if self.provider_id:
|
||||
# Setup from configuration
|
||||
self.capacity_wh = getattr(self.config, f"{self.prefix}_capacity")
|
||||
self.initial_soc_percentage = getattr(self.config, f"{self.prefix}_initial_soc")
|
||||
self.hours = self.total_hours # TODO where does that come from?
|
||||
self.charging_efficiency = getattr(self.config, f"{self.prefix}_charging_efficiency")
|
||||
self.discharging_efficiency = getattr(
|
||||
self.config, f"{self.prefix}_discharging_efficiency"
|
||||
)
|
||||
self.max_charge_power_w = getattr(self.config, f"{self.prefix}_max_charging_power")
|
||||
|
||||
if self.provider_id == "GenericBattery":
|
||||
self.min_soc_percentage = getattr(
|
||||
self.config,
|
||||
f"{self.prefix}_soc_min",
|
||||
)
|
||||
else:
|
||||
self.min_soc_percentage = 0
|
||||
|
||||
self.max_soc_percentage = getattr(
|
||||
self.config,
|
||||
f"{self.prefix}_soc_max",
|
||||
)
|
||||
elif self.parameters:
|
||||
# Setup from parameters
|
||||
self.capacity_wh = self.parameters.capacity_wh
|
||||
self.initial_soc_percentage = self.parameters.initial_soc_percentage
|
||||
self.charging_efficiency = self.parameters.charging_efficiency
|
||||
self.discharging_efficiency = self.parameters.discharging_efficiency
|
||||
self.max_charge_power_w = self.parameters.max_charge_power_w
|
||||
# Only assign for storage battery
|
||||
self.min_soc_percentage = (
|
||||
self.parameters.min_soc_percentage
|
||||
if isinstance(self.parameters, SolarPanelBatteryParameters)
|
||||
else 0
|
||||
)
|
||||
self.max_soc_percentage = self.parameters.max_soc_percentage
|
||||
else:
|
||||
error_msg = "Parameters and provider ID are missing. Cannot instantiate."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
# Only assign for storage battery
|
||||
self.min_soc_percentage = (
|
||||
self.parameters.min_soc_percentage
|
||||
if isinstance(self.parameters, SolarPanelBatteryParameters)
|
||||
else 0
|
||||
)
|
||||
self.max_soc_percentage = self.parameters.max_soc_percentage
|
||||
|
||||
# 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.discharge_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.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh
|
||||
|
||||
self.initialised = True
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Converts the object to a dictionary representation."""
|
||||
return {
|
||||
"device_id": self.device_id,
|
||||
"capacity_wh": self.capacity_wh,
|
||||
"initial_soc_percentage": self.initial_soc_percentage,
|
||||
"soc_wh": self.soc_wh,
|
||||
|
@@ -1,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.logging import get_logger
|
||||
from akkudoktoreos.devices.battery import Battery
|
||||
from akkudoktoreos.devices.devicesabc import DevicesBase
|
||||
from akkudoktoreos.devices.generic import HomeAppliance
|
||||
from akkudoktoreos.devices.inverter import Inverter
|
||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
from akkudoktoreos.devices.settings import DevicesCommonSettings
|
||||
|
||||
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):
|
||||
# Results of the devices simulation and
|
||||
# insights into various parameters over the entire forecast period.
|
||||
# -----------------------------------------------------------------
|
||||
last_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The load in watt-hours per hour."
|
||||
)
|
||||
eauto_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The state of charge of the EV for each hour."
|
||||
)
|
||||
einnahmen_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None,
|
||||
description="The revenue from grid feed-in or other sources in euros per hour.",
|
||||
)
|
||||
home_appliance_wh_per_hour: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None,
|
||||
description="The energy consumption of a household appliance in watt-hours per hour.",
|
||||
)
|
||||
kosten_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The costs in euros per hour."
|
||||
)
|
||||
grid_import_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The grid energy drawn in watt-hours per hour."
|
||||
)
|
||||
grid_export_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The energy fed into the grid in watt-hours per hour."
|
||||
)
|
||||
verluste_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None, description="The losses in watt-hours per hour."
|
||||
)
|
||||
akku_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field(
|
||||
default=None,
|
||||
description="The state of charge of the battery (not the EV) in percentage per hour.",
|
||||
)
|
||||
def __init__(self, settings: Optional[DevicesCommonSettings] = None):
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
super().__init__()
|
||||
if settings is None:
|
||||
settings = self.config.devices
|
||||
if settings is None:
|
||||
return
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_balance_euro(self) -> float:
|
||||
"""The total balance of revenues minus costs in euros."""
|
||||
return self.total_revenues_euro - self.total_costs_euro
|
||||
# initialize devices
|
||||
if settings.batteries is not None:
|
||||
for battery_params in settings.batteries:
|
||||
self.add_device(Battery(battery_params))
|
||||
if settings.inverters is not None:
|
||||
for inverter_params in settings.inverters:
|
||||
self.add_device(Inverter(inverter_params))
|
||||
if settings.home_appliances is not None:
|
||||
for home_appliance_params in settings.home_appliances:
|
||||
self.add_device(HomeAppliance(home_appliance_params))
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_revenues_euro(self) -> float:
|
||||
"""The total revenues in euros."""
|
||||
if self.einnahmen_euro_pro_stunde is None:
|
||||
return 0
|
||||
return np.nansum(self.einnahmen_euro_pro_stunde)
|
||||
self.post_setup()
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_costs_euro(self) -> float:
|
||||
"""The total costs in euros."""
|
||||
if self.kosten_euro_pro_stunde is None:
|
||||
return 0
|
||||
return np.nansum(self.kosten_euro_pro_stunde)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_losses_wh(self) -> float:
|
||||
"""The total losses in watt-hours over the entire period."""
|
||||
if self.verluste_wh_pro_stunde is None:
|
||||
return 0
|
||||
return np.nansum(self.verluste_wh_pro_stunde)
|
||||
|
||||
# Devices
|
||||
# TODO: Make devices class a container of device simulation providers.
|
||||
# Device simulations to be used are then enabled in the configuration.
|
||||
battery: ClassVar[Battery] = Battery(provider_id="GenericBattery")
|
||||
ev: ClassVar[Battery] = Battery(provider_id="GenericBEV")
|
||||
home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher")
|
||||
inverter: ClassVar[Inverter] = Inverter(
|
||||
self_consumption_predictor=SelfConsumptionProbabilityInterpolator,
|
||||
battery=battery,
|
||||
provider_id="GenericInverter",
|
||||
)
|
||||
|
||||
def update_data(self) -> None:
|
||||
"""Update device simulation data."""
|
||||
# Assure devices are set up
|
||||
self.battery.setup()
|
||||
self.ev.setup()
|
||||
self.home_appliance.setup()
|
||||
self.inverter.setup()
|
||||
|
||||
# Pre-allocate arrays for the results, optimized for speed
|
||||
self.last_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan)
|
||||
self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan)
|
||||
|
||||
# Set initial state
|
||||
simulation_step = to_duration("1 hour")
|
||||
if self.battery:
|
||||
self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage()
|
||||
if self.ev:
|
||||
self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage()
|
||||
|
||||
# Get predictions for full device simulation time range
|
||||
# gesamtlast[stunde]
|
||||
load_total_mean = self.prediction.key_to_array(
|
||||
"load_total_mean",
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=simulation_step,
|
||||
)
|
||||
# pv_prognose_wh[stunde]
|
||||
pvforecast_ac_power = self.prediction.key_to_array(
|
||||
"pvforecast_ac_power",
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=simulation_step,
|
||||
)
|
||||
# strompreis_euro_pro_wh[stunde]
|
||||
elecprice_marketprice_wh = self.prediction.key_to_array(
|
||||
"elecprice_marketprice_wh",
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=simulation_step,
|
||||
)
|
||||
# einspeiseverguetung_euro_pro_wh_arr[stunde]
|
||||
# TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr
|
||||
einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078)
|
||||
|
||||
for stunde_since_now in range(0, self.total_hours):
|
||||
hour = self.start_datetime.hour + stunde_since_now
|
||||
|
||||
# Accumulate loads and PV generation
|
||||
consumption = load_total_mean[stunde_since_now]
|
||||
self.verluste_wh_pro_stunde[stunde_since_now] = 0.0
|
||||
|
||||
# Home appliances
|
||||
if self.home_appliance:
|
||||
ha_load = self.home_appliance.get_load_for_hour(hour)
|
||||
consumption += ha_load
|
||||
self.home_appliance_wh_per_hour[stunde_since_now] = ha_load
|
||||
|
||||
# E-Auto handling
|
||||
if self.ev:
|
||||
if self.ev_charge_hours[hour] > 0:
|
||||
geladene_menge_eauto, verluste_eauto = self.ev.charge_energy(
|
||||
None, hour, relative_power=self.ev_charge_hours[hour]
|
||||
)
|
||||
consumption += geladene_menge_eauto
|
||||
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto
|
||||
self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage()
|
||||
|
||||
# Process inverter logic
|
||||
grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0)
|
||||
if self.battery:
|
||||
self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour)
|
||||
if self.inverter:
|
||||
generation = pvforecast_ac_power[hour]
|
||||
grid_export, grid_import, losses, self_consumption = self.inverter.process_energy(
|
||||
generation, consumption, hour
|
||||
)
|
||||
|
||||
# AC PV Battery Charge
|
||||
if self.battery and self.ac_charge_hours[hour] > 0.0:
|
||||
self.battery.set_charge_allowed_for_hour(1, hour)
|
||||
geladene_menge, verluste_wh = self.battery.charge_energy(
|
||||
None, hour, relative_power=self.ac_charge_hours[hour]
|
||||
)
|
||||
# print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage())
|
||||
consumption += geladene_menge
|
||||
grid_import += geladene_menge
|
||||
self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh
|
||||
|
||||
self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export
|
||||
self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import
|
||||
self.verluste_wh_pro_stunde[stunde_since_now] += losses
|
||||
self.last_wh_pro_stunde[stunde_since_now] = consumption
|
||||
|
||||
# Financial calculations
|
||||
self.kosten_euro_pro_stunde[stunde_since_now] = (
|
||||
grid_import * self.strompreis_euro_pro_wh[hour]
|
||||
)
|
||||
self.einnahmen_euro_pro_stunde[stunde_since_now] = (
|
||||
grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour]
|
||||
)
|
||||
|
||||
# battery SOC tracking
|
||||
if self.battery:
|
||||
self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage()
|
||||
else:
|
||||
self.akku_soc_pro_stunde[stunde_since_now] = 0.0
|
||||
|
||||
def report_dict(self) -> Dict[str, Any]:
|
||||
"""Provides devices simulation output as a dictionary."""
|
||||
out: Dict[str, Optional[Union[np.ndarray, float]]] = {
|
||||
"Last_Wh_pro_Stunde": self.last_wh_pro_stunde,
|
||||
"grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde,
|
||||
"grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde,
|
||||
"Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde,
|
||||
"akku_soc_pro_stunde": self.akku_soc_pro_stunde,
|
||||
"Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde,
|
||||
"Gesamtbilanz_Euro": self.total_balance_euro,
|
||||
"EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde,
|
||||
"Gesamteinnahmen_Euro": self.total_revenues_euro,
|
||||
"Gesamtkosten_Euro": self.total_costs_euro,
|
||||
"Verluste_Pro_Stunde": self.verluste_wh_pro_stunde,
|
||||
"Gesamt_Verluste": self.total_losses_wh,
|
||||
"Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour,
|
||||
}
|
||||
return out
|
||||
def post_setup(self) -> None:
|
||||
for device in self.devices.values():
|
||||
device.post_setup()
|
||||
|
||||
|
||||
# Initialize the Devices simulation, it is a singleton.
|
||||
|
@@ -1,22 +1,45 @@
|
||||
"""Abstract and base classes for devices."""
|
||||
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from typing import Optional, Type
|
||||
|
||||
from pendulum import DateTime
|
||||
from pydantic import ConfigDict, computed_field
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
from akkudoktoreos.core.coreabc import (
|
||||
ConfigMixin,
|
||||
DevicesMixin,
|
||||
EnergyManagementSystemMixin,
|
||||
PredictionMixin,
|
||||
)
|
||||
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
|
||||
|
||||
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):
|
||||
"""A mixin to manage start, end datetimes for devices data.
|
||||
|
||||
@@ -28,16 +51,16 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def end_datetime(self) -> Optional[DateTime]:
|
||||
"""Compute the end datetime based on the `start_datetime` and `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.
|
||||
|
||||
Returns:
|
||||
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
|
||||
"""
|
||||
if self.ems.start_datetime and self.config.prediction_hours:
|
||||
if self.ems.start_datetime and self.config.prediction.hours:
|
||||
end_datetime = self.ems.start_datetime + to_duration(
|
||||
f"{self.config.prediction_hours} hours"
|
||||
f"{self.config.prediction.hours} hours"
|
||||
)
|
||||
dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours
|
||||
logger.debug(
|
||||
@@ -68,33 +91,92 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
||||
return int(duration.total_hours())
|
||||
|
||||
|
||||
class DeviceBase(DevicesStartEndMixin, PredictionMixin):
|
||||
class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin):
|
||||
"""Base class for device simulations.
|
||||
|
||||
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
|
||||
`prediction`).
|
||||
Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute
|
||||
`prediction`) and EOS device registry (attribute `devices`).
|
||||
|
||||
Note:
|
||||
Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
|
||||
Behavior:
|
||||
- Several initialization phases (setup, post_setup):
|
||||
- setup: Initialize class attributes from DeviceParameters (pydantic input validation)
|
||||
- post_setup: Set connections between devices
|
||||
- NotImplemented:
|
||||
- hooks during optimization
|
||||
|
||||
Notes:
|
||||
- This class is base to concrete devices like battery, inverter, etc. that are used in optimization.
|
||||
- Not a pydantic model for a low footprint during optimization.
|
||||
"""
|
||||
|
||||
# Disable validation on assignment to speed up simulation runs.
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=False,
|
||||
)
|
||||
def __init__(self, parameters: Optional[DeviceParameters] = None):
|
||||
self.device_id: str = "<invalid>"
|
||||
self.parameters: Optional[DeviceParameters] = None
|
||||
self.hours = -1
|
||||
if self.total_hours is not None:
|
||||
self.hours = self.total_hours
|
||||
|
||||
self.initialized = DeviceState.UNINITIALIZED
|
||||
|
||||
if parameters is not None:
|
||||
self.setup(parameters)
|
||||
|
||||
def setup(self, parameters: DeviceParameters) -> None:
|
||||
if self.initialized != DeviceState.UNINITIALIZED:
|
||||
return
|
||||
|
||||
self.parameters = parameters
|
||||
self.device_id = self.parameters.device_id
|
||||
|
||||
if self.parameters.hours is not None:
|
||||
self.hours = self.parameters.hours
|
||||
if self.hours < 0:
|
||||
raise ValueError("hours is unset")
|
||||
|
||||
self._setup()
|
||||
|
||||
self.initialized = DeviceState.PREPARED
|
||||
|
||||
def post_setup(self) -> None:
|
||||
if self.initialized.value >= DeviceState.INITIALIZED.value:
|
||||
return
|
||||
|
||||
self._post_setup()
|
||||
self.initialized = DeviceState.INITIALIZED
|
||||
|
||||
def _setup(self) -> None:
|
||||
"""Implement custom setup in derived device classes."""
|
||||
pass
|
||||
|
||||
def _post_setup(self) -> None:
|
||||
"""Implement custom setup in derived device classes that is run when all devices are initialized."""
|
||||
pass
|
||||
|
||||
|
||||
class DevicesBase(DevicesStartEndMixin, PredictionMixin, PydanticBaseModel):
|
||||
class DevicesBase(DevicesStartEndMixin, PredictionMixin):
|
||||
"""Base class for handling device data.
|
||||
|
||||
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
|
||||
`prediction`).
|
||||
|
||||
Note:
|
||||
Validation on assignment of the Pydantic model is disabled to speed up simulation runs.
|
||||
"""
|
||||
|
||||
# Disable validation on assignment to speed up simulation runs.
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=False,
|
||||
)
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.devices: dict[str, "DeviceBase"] = dict()
|
||||
|
||||
def get_device_by_id(self, device_id: str) -> Optional["DeviceBase"]:
|
||||
return self.devices.get(device_id)
|
||||
|
||||
def add_device(self, device: Optional["DeviceBase"]) -> None:
|
||||
if device is None:
|
||||
return
|
||||
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()
|
||||
|
@@ -4,20 +4,24 @@ import numpy as np
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
|
||||
|
||||
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(
|
||||
gt=0,
|
||||
description="An integer representing the energy consumption of a household device in watt-hours.",
|
||||
examples=[2000],
|
||||
)
|
||||
duration_h: int = Field(
|
||||
gt=0,
|
||||
description="An integer representing the usage duration of a household device in hours.",
|
||||
examples=[3],
|
||||
)
|
||||
|
||||
|
||||
@@ -25,46 +29,15 @@ class HomeAppliance(DeviceBase):
|
||||
def __init__(
|
||||
self,
|
||||
parameters: Optional[HomeApplianceParameters] = None,
|
||||
hours: Optional[int] = 24,
|
||||
provider_id: Optional[str] = None,
|
||||
):
|
||||
# Configuration initialisation
|
||||
self.provider_id = provider_id
|
||||
self.prefix = "<invalid>"
|
||||
if self.provider_id == "GenericDishWasher":
|
||||
self.prefix = "dishwasher"
|
||||
# Parameter initialisiation
|
||||
self.parameters = parameters
|
||||
if hours is None:
|
||||
self.hours = self.total_hours
|
||||
else:
|
||||
self.hours = hours
|
||||
self.parameters: Optional[HomeApplianceParameters] = None
|
||||
super().__init__(parameters)
|
||||
|
||||
self.initialised = False
|
||||
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
|
||||
if self.parameters is not None:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
if self.initialised:
|
||||
return
|
||||
if self.provider_id is not None:
|
||||
# Setup by configuration
|
||||
self.hours = self.total_hours
|
||||
self.consumption_wh = getattr(self.config, f"{self.prefix}_consumption")
|
||||
self.duration_h = getattr(self.config, f"{self.prefix}_duration")
|
||||
elif self.parameters is not None:
|
||||
# Setup by parameters
|
||||
self.consumption_wh = (
|
||||
self.parameters.consumption_wh
|
||||
) # Total energy consumption of the device in kWh
|
||||
self.duration_h = self.parameters.duration_h # Duration of use in hours
|
||||
else:
|
||||
error_msg = "Parameters and provider ID missing. Can't instantiate."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
def _setup(self) -> None:
|
||||
assert self.parameters is not None
|
||||
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:
|
||||
"""Sets the start time of the device and generates the corresponding load curve.
|
||||
|
@@ -18,9 +18,9 @@ class Heatpump:
|
||||
COP_COEFFICIENT = 0.1
|
||||
"""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.prediction_hours = prediction_hours
|
||||
self.hours = hours
|
||||
self.log = logging.getLogger(__name__)
|
||||
|
||||
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."""
|
||||
power_data: List[float] = []
|
||||
|
||||
if len(temperatures) != self.prediction_hours:
|
||||
if len(temperatures) != self.hours:
|
||||
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."
|
||||
)
|
||||
|
||||
|
@@ -1,64 +1,48 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from scipy.interpolate import RegularGridInterpolator
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.devices.battery import Battery
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
|
||||
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InverterParameters(ParametersBaseModel):
|
||||
max_power_wh: float = Field(gt=0)
|
||||
class InverterParameters(DeviceParameters):
|
||||
"""Inverter Device Simulation Configuration."""
|
||||
|
||||
device_id: str = Field(description="ID of inverter", examples=["inverter1"])
|
||||
max_power_wh: float = Field(gt=0, examples=[10000])
|
||||
battery_id: Optional[str] = Field(
|
||||
default=None, description="ID of battery", examples=[None, "battery1"]
|
||||
)
|
||||
|
||||
|
||||
class Inverter(DeviceBase):
|
||||
def __init__(
|
||||
self,
|
||||
self_consumption_predictor: RegularGridInterpolator,
|
||||
parameters: Optional[InverterParameters] = None,
|
||||
battery: Optional[Battery] = None,
|
||||
provider_id: Optional[str] = None,
|
||||
):
|
||||
# Configuration initialisation
|
||||
self.provider_id = provider_id
|
||||
self.prefix = "<invalid>"
|
||||
if self.provider_id == "GenericInverter":
|
||||
self.prefix = "inverter"
|
||||
# Parameter initialisiation
|
||||
self.parameters = parameters
|
||||
if battery is None:
|
||||
self.parameters: Optional[InverterParameters] = None
|
||||
super().__init__(parameters)
|
||||
|
||||
def _setup(self) -> None:
|
||||
assert self.parameters is not None
|
||||
if self.parameters.battery_id is None:
|
||||
# For the moment raise exception
|
||||
# TODO: Make battery configurable by config
|
||||
error_msg = "Battery for PV inverter is mandatory."
|
||||
logger.error(error_msg)
|
||||
raise NotImplementedError(error_msg)
|
||||
self.battery = battery # Connection to a battery object
|
||||
self.self_consumption_predictor = self_consumption_predictor
|
||||
self.self_consumption_predictor = get_eos_load_interpolator()
|
||||
self.max_power_wh = (
|
||||
self.parameters.max_power_wh
|
||||
) # Maximum power that the inverter can handle
|
||||
|
||||
self.initialised = False
|
||||
# Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised.
|
||||
if self.parameters is not None:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
if self.initialised:
|
||||
return
|
||||
if self.provider_id is not None:
|
||||
# Setup by configuration
|
||||
self.max_power_wh = getattr(self.config, f"{self.prefix}_power_max")
|
||||
elif self.parameters is not None:
|
||||
# Setup by parameters
|
||||
self.max_power_wh = (
|
||||
self.parameters.max_power_wh # Maximum power that the inverter can handle
|
||||
)
|
||||
else:
|
||||
error_msg = "Parameters and provider ID missing. Can't instantiate."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
def _post_setup(self) -> None:
|
||||
assert self.parameters is not None
|
||||
self.battery = self.devices.get_device_by_id(self.parameters.battery_id)
|
||||
|
||||
def process_energy(
|
||||
self, generation: float, consumption: float, hour: int
|
||||
|
27
src/akkudoktoreos/devices/settings.py
Normal file
27
src/akkudoktoreos/devices/settings.py
Normal 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=[[]]
|
||||
)
|
@@ -23,20 +23,22 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MeasurementCommonSettings(SettingsBaseModel):
|
||||
measurement_load0_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load0 source (e.g. 'Household', 'Heat Pump')"
|
||||
"""Measurement Configuration."""
|
||||
|
||||
load0_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load0 source", examples=["Household", "Heat Pump"]
|
||||
)
|
||||
measurement_load1_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load1 source (e.g. 'Household', 'Heat Pump')"
|
||||
load1_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load1 source", examples=[None]
|
||||
)
|
||||
measurement_load2_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load2 source (e.g. 'Household', 'Heat Pump')"
|
||||
load2_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load2 source", examples=[None]
|
||||
)
|
||||
measurement_load3_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load3 source (e.g. 'Household', 'Heat Pump')"
|
||||
load3_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load3 source", examples=[None]
|
||||
)
|
||||
measurement_load4_name: Optional[str] = Field(
|
||||
default=None, description="Name of the load4 source (e.g. 'Household', 'Heat Pump')"
|
||||
load4_name: Optional[str] = Field(
|
||||
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
|
||||
measurement_load0_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load0 meter reading [kWh]"
|
||||
load0_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load0 meter reading [kWh]", examples=[40421]
|
||||
)
|
||||
measurement_load1_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load1 meter reading [kWh]"
|
||||
load1_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load1 meter reading [kWh]", examples=[None]
|
||||
)
|
||||
measurement_load2_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load2 meter reading [kWh]"
|
||||
load2_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load2 meter reading [kWh]", examples=[None]
|
||||
)
|
||||
measurement_load3_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load3 meter reading [kWh]"
|
||||
load3_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load3 meter reading [kWh]", examples=[None]
|
||||
)
|
||||
measurement_load4_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Load4 meter reading [kWh]"
|
||||
load4_mr: Optional[float] = Field(
|
||||
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(
|
||||
default=None, ge=0, description="Export to grid meter reading [kWh]"
|
||||
grid_export_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Export to grid meter reading [kWh]", examples=[1000]
|
||||
)
|
||||
|
||||
measurement_grid_import_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Import from grid meter reading [kWh]"
|
||||
grid_import_mr: Optional[float] = Field(
|
||||
default=None, ge=0, description="Import from grid meter reading [kWh]", examples=[1000]
|
||||
)
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def measurement_loads(self) -> List[str]:
|
||||
def loads(self) -> List[str]:
|
||||
"""Compute a list of active loads."""
|
||||
active_loads = []
|
||||
|
||||
# Loop through measurement_loadx
|
||||
for i in range(self.measurement_max_loads):
|
||||
load_attr = f"measurement_load{i}_mr"
|
||||
# Loop through loadx
|
||||
for i in range(self.max_loads):
|
||||
load_attr = f"load{i}_mr"
|
||||
|
||||
# Check if either attribute is set and add to active loads
|
||||
if getattr(self, load_attr, None):
|
||||
@@ -103,9 +105,14 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
||||
)
|
||||
|
||||
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(
|
||||
self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration
|
||||
) -> int:
|
||||
@@ -143,11 +150,16 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
||||
if topic not in self.topics:
|
||||
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
|
||||
if topic == "measurement_load":
|
||||
if topic == "load":
|
||||
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"
|
||||
break
|
||||
|
||||
@@ -243,9 +255,9 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
||||
end_datetime = self[-1].date_time
|
||||
size = self._interval_count(start_datetime, end_datetime, interval)
|
||||
load_total_array = np.zeros(size)
|
||||
# Loop through measurement_load<x>_mr
|
||||
for i in range(self.record_class().measurement_max_loads):
|
||||
key = f"measurement_load{i}_mr"
|
||||
# Loop through load<x>_mr
|
||||
for i in range(self.record_class().max_loads):
|
||||
key = f"load{i}_mr"
|
||||
# Calculate load per interval
|
||||
load_array = self._energy_from_meter_readings(
|
||||
key=key, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -25,7 +24,6 @@ from akkudoktoreos.devices.battery import (
|
||||
)
|
||||
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
||||
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -112,8 +110,8 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
):
|
||||
"""Initialize the optimization problem with the required parameters."""
|
||||
self.opti_param: dict[str, Any] = {}
|
||||
self.fixed_eauto_hours = self.config.prediction_hours - self.config.optimization_hours
|
||||
self.possible_charge_values = self.config.optimization_ev_available_charge_rates_percent
|
||||
self.fixed_eauto_hours = self.config.prediction.hours - self.config.optimization.hours
|
||||
self.possible_charge_values = self.config.optimization.ev_available_charge_rates_percent
|
||||
self.verbose = verbose
|
||||
self.fix_seed = fixed_seed
|
||||
self.optimize_ev = True
|
||||
@@ -180,23 +178,23 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
total_states = 3 * len_ac
|
||||
|
||||
# 1. Mutating the charge_discharge part
|
||||
charge_discharge_part = individual[: self.config.prediction_hours]
|
||||
charge_discharge_part = individual[: self.config.prediction.hours]
|
||||
(charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part)
|
||||
|
||||
# Instead of a fixed clamping to 0..8 or 0..6 dynamically:
|
||||
charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1)
|
||||
individual[: self.config.prediction_hours] = charge_discharge_mutated
|
||||
individual[: self.config.prediction.hours] = charge_discharge_mutated
|
||||
|
||||
# 2. Mutating the EV charge part, if active
|
||||
if self.optimize_ev:
|
||||
ev_charge_part = individual[
|
||||
self.config.prediction_hours : self.config.prediction_hours * 2
|
||||
self.config.prediction.hours : self.config.prediction.hours * 2
|
||||
]
|
||||
(ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part)
|
||||
ev_charge_part_mutated[self.config.prediction_hours - self.fixed_eauto_hours :] = [
|
||||
ev_charge_part_mutated[self.config.prediction.hours - self.fixed_eauto_hours :] = [
|
||||
0
|
||||
] * self.fixed_eauto_hours
|
||||
individual[self.config.prediction_hours : self.config.prediction_hours * 2] = (
|
||||
individual[self.config.prediction.hours : self.config.prediction.hours * 2] = (
|
||||
ev_charge_part_mutated
|
||||
)
|
||||
|
||||
@@ -212,13 +210,13 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
def create_individual(self) -> list[int]:
|
||||
# Start with discharge states for the individual
|
||||
individual_components = [
|
||||
self.toolbox.attr_discharge_state() for _ in range(self.config.prediction_hours)
|
||||
self.toolbox.attr_discharge_state() for _ in range(self.config.prediction.hours)
|
||||
]
|
||||
|
||||
# Add EV charge index values if optimize_ev is True
|
||||
if self.optimize_ev:
|
||||
individual_components += [
|
||||
self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction_hours)
|
||||
self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction.hours)
|
||||
]
|
||||
|
||||
# Add the start time of the household appliance if it's being optimized
|
||||
@@ -251,7 +249,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
individual.extend(eautocharge_hours_index.tolist())
|
||||
elif self.optimize_ev:
|
||||
# Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu
|
||||
individual.extend([0] * self.config.prediction_hours)
|
||||
individual.extend([0] * self.config.prediction.hours)
|
||||
|
||||
# Add dishwasher start time if applicable
|
||||
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None:
|
||||
@@ -273,12 +271,13 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
3. Dishwasher start time (integer if applicable).
|
||||
"""
|
||||
# Discharge hours as a NumPy array of ints
|
||||
discharge_hours_bin = np.array(individual[: self.config.prediction_hours], dtype=int)
|
||||
discharge_hours_bin = np.array(individual[: self.config.prediction.hours], dtype=int)
|
||||
|
||||
# EV charge hours as a NumPy array of ints (if optimize_ev is True)
|
||||
eautocharge_hours_index = (
|
||||
# append ev charging states to individual
|
||||
np.array(
|
||||
individual[self.config.prediction_hours : self.config.prediction_hours * 2],
|
||||
individual[self.config.prediction.hours : self.config.prediction.hours * 2],
|
||||
dtype=int,
|
||||
)
|
||||
if self.optimize_ev
|
||||
@@ -390,7 +389,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
)
|
||||
self.ems.set_ev_charge_hours(eautocharge_hours_float)
|
||||
else:
|
||||
self.ems.set_ev_charge_hours(np.full(self.config.prediction_hours, 0))
|
||||
self.ems.set_ev_charge_hours(np.full(self.config.prediction.hours, 0))
|
||||
|
||||
return self.ems.simulate(self.ems.start_datetime.hour)
|
||||
|
||||
@@ -452,7 +451,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
# min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size)
|
||||
# battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:]
|
||||
# discharge_hours_bin_tail = discharge_hours_bin[-min_length:]
|
||||
# len_ac = len(self.config.optimization_ev_available_charge_rates_percent)
|
||||
# len_ac = len(self.config.optimization.ev_available_charge_rates_percent)
|
||||
|
||||
# # # Find hours where battery SoC is 0
|
||||
# # zero_soc_mask = battery_soc_per_hour_tail == 0
|
||||
@@ -501,7 +500,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
if parameters.eauto and self.ems.ev
|
||||
else 0
|
||||
)
|
||||
* self.config.optimization_penalty,
|
||||
* self.config.optimization.penalty,
|
||||
)
|
||||
|
||||
return (gesamtbilanz,)
|
||||
@@ -569,30 +568,26 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
start_hour = self.ems.start_datetime.hour
|
||||
|
||||
einspeiseverguetung_euro_pro_wh = np.full(
|
||||
self.config.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh
|
||||
self.config.prediction.hours, parameters.ems.einspeiseverguetung_euro_pro_wh
|
||||
)
|
||||
|
||||
# 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate
|
||||
sc = SelfConsumptionProbabilityInterpolator(
|
||||
Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
|
||||
)
|
||||
# TODO: Refactor device setup phase out
|
||||
self.devices.reset()
|
||||
|
||||
# Initialize PV and EV batteries
|
||||
akku: Optional[Battery] = None
|
||||
if parameters.pv_akku:
|
||||
akku = Battery(
|
||||
parameters.pv_akku,
|
||||
hours=self.config.prediction_hours,
|
||||
)
|
||||
akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1))
|
||||
akku = Battery(parameters.pv_akku)
|
||||
self.devices.add_device(akku)
|
||||
akku.set_charge_per_hour(np.full(self.config.prediction.hours, 1))
|
||||
|
||||
eauto: Optional[Battery] = None
|
||||
if parameters.eauto:
|
||||
eauto = Battery(
|
||||
parameters.eauto,
|
||||
hours=self.config.prediction_hours,
|
||||
)
|
||||
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 = (
|
||||
parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0
|
||||
)
|
||||
@@ -603,20 +598,22 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
dishwasher = (
|
||||
HomeAppliance(
|
||||
parameters=parameters.dishwasher,
|
||||
hours=self.config.prediction_hours,
|
||||
)
|
||||
if parameters.dishwasher is not None
|
||||
else None
|
||||
)
|
||||
self.devices.add_device(dishwasher)
|
||||
|
||||
# Initialize the inverter and energy management system
|
||||
inverter: Optional[Inverter] = None
|
||||
if parameters.inverter:
|
||||
inverter = Inverter(
|
||||
sc,
|
||||
parameters.inverter,
|
||||
akku,
|
||||
)
|
||||
self.devices.add_device(inverter)
|
||||
|
||||
self.devices.post_setup()
|
||||
|
||||
self.ems.set_parameters(
|
||||
parameters.ems,
|
||||
inverter=inverter,
|
||||
|
@@ -9,21 +9,19 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OptimizationCommonSettings(SettingsBaseModel):
|
||||
"""Base configuration for optimization settings.
|
||||
"""General Optimization Configuration.
|
||||
|
||||
Attributes:
|
||||
optimization_hours (int): Number of hours for optimizations.
|
||||
hours (int): Number of hours for optimizations.
|
||||
"""
|
||||
|
||||
optimization_hours: Optional[int] = Field(
|
||||
default=24, ge=0, description="Number of hours into the future for optimizations."
|
||||
hours: Optional[int] = Field(
|
||||
default=48, ge=0, description="Number of hours into the future for optimizations."
|
||||
)
|
||||
|
||||
optimization_penalty: Optional[int] = Field(
|
||||
default=10, description="Penalty factor used in optimization."
|
||||
)
|
||||
penalty: Optional[int] = Field(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=[
|
||||
0.0,
|
||||
6.0 / 16.0,
|
||||
|
@@ -3,12 +3,21 @@ from typing import Optional
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||
|
||||
|
||||
class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
elecprice_provider: Optional[str] = Field(
|
||||
default=None, description="Electricity price provider id of provider to be used."
|
||||
"""Electricity Price Prediction Configuration."""
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Electricity price provider id of provider to be used.",
|
||||
examples=["ElecPriceAkkudoktor"],
|
||||
)
|
||||
elecprice_charges_kwh: Optional[float] = Field(
|
||||
default=None, ge=0, description="Electricity price charges (€/kWh)."
|
||||
charges_kwh: Optional[float] = Field(
|
||||
default=None, ge=0, description="Electricity price charges (€/kWh).", examples=[0.21]
|
||||
)
|
||||
|
||||
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
|
||||
default=None, description="Provider settings", examples=[None]
|
||||
)
|
||||
|
@@ -49,15 +49,15 @@ class ElecPriceProvider(PredictionProvider):
|
||||
electricity price_provider (str): Prediction provider for electricity price.
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
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
|
||||
@@ -71,4 +71,4 @@ class ElecPriceProvider(PredictionProvider):
|
||||
return "ElecPriceProvider"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self.provider_id() == self.config.elecprice_provider
|
||||
return self.provider_id() == self.config.elecprice.provider
|
||||
|
@@ -54,11 +54,11 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int, optional): Number of hours in the future for the forecast.
|
||||
prediction_historic_hours (int, optional): Number of past hours for retaining data.
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_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 `historic_hours`.
|
||||
|
||||
Methods:
|
||||
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
|
||||
date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}"
|
||||
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.general.timezone}"
|
||||
response = requests.get(url)
|
||||
logger.debug(f"Response from {url}: {response}")
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
akkudoktor_data = self._validate_data(response.content)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return akkudoktor_data
|
||||
|
||||
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)
|
||||
return capped_data
|
||||
|
||||
def _predict_ets(
|
||||
self, history: np.ndarray, seasonal_periods: int, prediction_hours: int
|
||||
) -> np.ndarray:
|
||||
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
model = ExponentialSmoothing(
|
||||
clean_history, seasonal="add", seasonal_periods=seasonal_periods
|
||||
).fit()
|
||||
return model.forecast(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)
|
||||
return np.full(prediction_hours, np.median(clean_history))
|
||||
return np.full(hours, np.median(clean_history))
|
||||
|
||||
def _update_data(
|
||||
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
|
||||
# in ascending order and have the same timestamps.
|
||||
|
||||
# Get elecprice_charges_kwh in wh
|
||||
charges_wh = (self.config.elecprice_charges_kwh or 0) / 1000
|
||||
# Get charges_kwh in wh
|
||||
charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
|
||||
|
||||
highest_orig_datetime = None # newest datetime from the api after that we want to update.
|
||||
series_data = pd.Series(dtype=float) # Initialize an empty series
|
||||
|
||||
for value in akkudoktor_data.values:
|
||||
orig_datetime = to_datetime(value.start, in_timezone=self.config.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:
|
||||
highest_orig_datetime = orig_datetime
|
||||
|
||||
@@ -183,27 +181,23 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
assert highest_orig_datetime # mypy fix
|
||||
|
||||
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
|
||||
needed_prediction_hours = int(
|
||||
self.config.prediction_hours
|
||||
needed_hours = int(
|
||||
self.config.prediction.hours
|
||||
- ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
|
||||
)
|
||||
|
||||
if needed_prediction_hours <= 0:
|
||||
if needed_hours <= 0:
|
||||
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}"
|
||||
) # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records
|
||||
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
|
||||
return
|
||||
|
||||
if amount_datasets > 800: # we do the full ets with seasons of 1 week
|
||||
prediction = self._predict_ets(
|
||||
history, seasonal_periods=168, prediction_hours=needed_prediction_hours
|
||||
)
|
||||
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
|
||||
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
|
||||
prediction = self._predict_ets(
|
||||
history, seasonal_periods=24, prediction_hours=needed_prediction_hours
|
||||
)
|
||||
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
|
||||
elif amount_datasets > 0: # not enough data for ets, do median
|
||||
prediction = self._predict_median(history, prediction_hours=needed_prediction_hours)
|
||||
prediction = self._predict_median(history, hours=needed_hours)
|
||||
else:
|
||||
logger.error("No data available for prediction")
|
||||
raise ValueError("No data available")
|
||||
|
@@ -22,21 +22,22 @@ logger = get_logger(__name__)
|
||||
class ElecPriceImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for elecprice data import from file or JSON String."""
|
||||
|
||||
elecpriceimport_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None, description="Path to the file to import elecprice data from."
|
||||
import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None,
|
||||
description="Path to the file to import elecprice data from.",
|
||||
examples=[None, "/path/to/prices.json"],
|
||||
)
|
||||
|
||||
elecpriceimport_json: Optional[str] = Field(
|
||||
import_json: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JSON string, dictionary of electricity price forecast value lists.",
|
||||
examples=['{"elecprice_marketprice_wh": [0.0003384, 0.0003318, 0.0003284]}'],
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("elecpriceimport_file_path", mode="after")
|
||||
@field_validator("import_file_path", mode="after")
|
||||
@classmethod
|
||||
def validate_elecpriceimport_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:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
@@ -62,7 +63,12 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
|
||||
return "ElecPriceImport"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
if self.config.elecpriceimport_file_path is not None:
|
||||
self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice")
|
||||
if self.config.elecpriceimport_json is not None:
|
||||
self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice")
|
||||
if self.config.elecprice.provider_settings.import_file_path:
|
||||
self.import_from_file(
|
||||
self.config.elecprice.provider_settings.import_file_path,
|
||||
key_prefix="elecprice",
|
||||
)
|
||||
if self.config.elecprice.provider_settings.import_json:
|
||||
self.import_from_json(
|
||||
self.config.elecprice.provider_settings.import_json, key_prefix="elecprice"
|
||||
)
|
||||
|
@@ -6,6 +6,8 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.interpolate import RegularGridInterpolator
|
||||
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
|
||||
|
||||
class SelfConsumptionProbabilityInterpolator:
|
||||
def __init__(self, filepath: str | Path):
|
||||
@@ -67,5 +69,17 @@ class SelfConsumptionProbabilityInterpolator:
|
||||
# return self_consumption_rate
|
||||
|
||||
|
||||
# Test the function
|
||||
# print(calculate_self_consumption(1000, 1200))
|
||||
class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin):
|
||||
def __init__(self) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
filename = Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
|
||||
super().__init__(filename)
|
||||
|
||||
|
||||
# Initialize the Energy Management System, it is a singleton.
|
||||
eos_load_interpolator = EOSLoadInterpolator()
|
||||
|
||||
|
||||
def get_eos_load_interpolator() -> EOSLoadInterpolator:
|
||||
return eos_load_interpolator
|
||||
|
@@ -1,18 +1,26 @@
|
||||
"""Load forecast module for load predictions."""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoadCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for loaod forecast providers."""
|
||||
"""Load Prediction Configuration."""
|
||||
|
||||
load_provider: Optional[str] = Field(
|
||||
default=None, description="Load provider id of provider to be used."
|
||||
provider: Optional[str] = Field(
|
||||
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])
|
||||
)
|
||||
|
@@ -33,18 +33,18 @@ class LoadProvider(PredictionProvider):
|
||||
LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
|
||||
Configuration variables:
|
||||
load_provider (str): Prediction provider for load.
|
||||
provider (str): Prediction provider for load.
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
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
|
||||
@@ -58,4 +58,4 @@ class LoadProvider(PredictionProvider):
|
||||
return "LoadProvider"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self.provider_id() == self.config.load_provider
|
||||
return self.provider_id() == self.config.load.provider
|
||||
|
@@ -17,7 +17,7 @@ class LoadAkkudoktorCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for load data import from file."""
|
||||
|
||||
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"]))
|
||||
)
|
||||
# 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:
|
||||
error_msg = f"Error: File {load_file} not found."
|
||||
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.
|
||||
# End date for prediction is prediction hours from now.
|
||||
date = self.start_datetime.start_of("day")
|
||||
end_date = self.start_datetime.add(hours=self.config.prediction_hours)
|
||||
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
|
||||
while compare_datetimes(date, end_date).lt:
|
||||
# Extract mean (index 0) and standard deviation (index 1) for the given day and hour
|
||||
# Day indexing starts at 0, -1 because of that
|
||||
@@ -127,4 +129,4 @@ class LoadAkkudoktor(LoadProvider):
|
||||
self.update_value(date, values)
|
||||
date += to_duration("1 hour")
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
|
@@ -22,15 +22,19 @@ logger = get_logger(__name__)
|
||||
class LoadImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for load data import from file or JSON string."""
|
||||
|
||||
load_import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None, description="Path to the file to import load data from."
|
||||
import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None,
|
||||
description="Path to the file to import load data from.",
|
||||
examples=[None, "/path/to/yearly_load.json"],
|
||||
)
|
||||
load_import_json: Optional[str] = Field(
|
||||
default=None, description="JSON string, dictionary of load forecast value lists."
|
||||
import_json: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JSON string, dictionary of load forecast value lists.",
|
||||
examples=['{"load0_mean": [676.71, 876.19, 527.13]}'],
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("load_import_file_path", mode="after")
|
||||
@field_validator("import_file_path", mode="after")
|
||||
@classmethod
|
||||
def validate_loadimport_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
|
||||
if value is None:
|
||||
@@ -58,7 +62,7 @@ class LoadImport(LoadProvider, PredictionImportProvider):
|
||||
return "LoadImport"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
if self.config.load_import_file_path is not None:
|
||||
self.import_from_file(self.config.load_import_file_path, key_prefix="load")
|
||||
if self.config.load_import_json is not None:
|
||||
self.import_from_json(self.config.load_import_json, key_prefix="load")
|
||||
if self.config.load.provider_settings.import_file_path:
|
||||
self.import_from_file(self.config.provider_settings.import_file_path, key_prefix="load")
|
||||
if self.config.load.provider_settings.import_json:
|
||||
self.import_from_json(self.config.load.provider_settings.import_json, key_prefix="load")
|
||||
|
@@ -28,7 +28,7 @@ Attributes:
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import Field, computed_field
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
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.weatherclearoutside import WeatherClearOutside
|
||||
from akkudoktoreos.prediction.weatherimport import WeatherImport
|
||||
from akkudoktoreos.utils.datetimeutil import to_timezone
|
||||
|
||||
|
||||
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
|
||||
parameters such as the forecast duration (in hours) and location (latitude and longitude).
|
||||
Validators ensure each parameter is within a specified range. A computed property, `timezone`,
|
||||
determines the time zone based on latitude and longitude.
|
||||
parameters such as the forecast duration (in hours).
|
||||
Validators ensure each parameter is within a specified range.
|
||||
|
||||
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.
|
||||
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.
|
||||
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_prediction_hours (int): Ensures `prediction_hours` is a non-negative integer.
|
||||
validate_prediction_historic_hours (int): Ensures `prediction_historic_hours` is a non-negative integer.
|
||||
validate_latitude (float): Ensures `latitude` is within the range -90 to 90.
|
||||
validate_longitude (float): Ensures `longitude` is within the range -180 to 180.
|
||||
validate_hours (int): Ensures `hours` is a non-negative integer.
|
||||
validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.
|
||||
"""
|
||||
|
||||
prediction_hours: Optional[int] = Field(
|
||||
hours: Optional[int] = Field(
|
||||
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,
|
||||
ge=0,
|
||||
description="Number of hours into the past for historical predictions data",
|
||||
)
|
||||
latitude: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=-90.0,
|
||||
le=90.0,
|
||||
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
|
||||
)
|
||||
longitude: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=-180.0,
|
||||
le=180.0,
|
||||
description="Longitude in decimal degrees, within -180 to 180 (°)",
|
||||
)
|
||||
|
||||
# Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def timezone(self) -> Optional[str]:
|
||||
"""Compute timezone based on latitude and longitude."""
|
||||
if self.latitude and self.longitude:
|
||||
return to_timezone(location=(self.latitude, self.longitude), as_string=True)
|
||||
return None
|
||||
|
||||
|
||||
class Prediction(PredictionContainer):
|
||||
|
@@ -114,16 +114,16 @@ class PredictionStartEndKeepMixin(PredictionBase):
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def end_datetime(self) -> Optional[DateTime]:
|
||||
"""Compute the end datetime based on the `start_datetime` and `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.
|
||||
|
||||
Returns:
|
||||
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
|
||||
"""
|
||||
if self.start_datetime and self.config.prediction_hours:
|
||||
if self.start_datetime and self.config.prediction.hours:
|
||||
end_datetime = self.start_datetime + to_duration(
|
||||
f"{self.config.prediction_hours} hours"
|
||||
f"{self.config.prediction.hours} hours"
|
||||
)
|
||||
dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours
|
||||
logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}")
|
||||
@@ -147,10 +147,10 @@ class PredictionStartEndKeepMixin(PredictionBase):
|
||||
return None
|
||||
historic_hours = self.historic_hours_min()
|
||||
if (
|
||||
self.config.prediction_historic_hours
|
||||
and self.config.prediction_historic_hours > historic_hours
|
||||
self.config.prediction.historic_hours
|
||||
and self.config.prediction.historic_hours > historic_hours
|
||||
):
|
||||
historic_hours = int(self.config.prediction_historic_hours)
|
||||
historic_hours = int(self.config.prediction.historic_hours)
|
||||
return self.start_datetime - to_duration(f"{historic_hours} hours")
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
|
@@ -1,469 +1,229 @@
|
||||
"""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.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__)
|
||||
|
||||
|
||||
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):
|
||||
"""PV Forecast Configuration."""
|
||||
|
||||
# General plane parameters
|
||||
# https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html
|
||||
# Inverter Parameters
|
||||
# https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/inverter.html
|
||||
|
||||
pvforecast_provider: Optional[str] = Field(
|
||||
default=None, description="PVForecast provider id of provider to be used."
|
||||
)
|
||||
# pvforecast0_latitude: Optional[float] = Field(default=None, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)")
|
||||
# Plane 0
|
||||
pvforecast0_surface_tilt: Optional[float] = Field(
|
||||
default=None, description="Tilt angle from horizontal plane. Ignored for two-axis tracking."
|
||||
)
|
||||
pvforecast0_surface_azimuth: Optional[float] = Field(
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
)
|
||||
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."
|
||||
description="PVForecast provider id of provider to be used.",
|
||||
examples=["PVForecastAkkudoktor"],
|
||||
)
|
||||
|
||||
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]
|
||||
@property
|
||||
def pvforecast_planes(self) -> List[str]:
|
||||
"""Compute a list of active planes."""
|
||||
active_planes = []
|
||||
|
||||
# Loop through pvforecast0 to pvforecast4
|
||||
for i in range(self.pvforecast_max_planes):
|
||||
plane = f"pvforecast{i}"
|
||||
tackingtype_attr = f"{plane}_trackingtype"
|
||||
tilt_attr = f"{plane}_surface_tilt"
|
||||
azimuth_attr = f"{plane}_surface_azimuth"
|
||||
|
||||
# Check if either attribute is set and add to active planes
|
||||
if getattr(self, tackingtype_attr, None) == 2:
|
||||
# Tilt angle from horizontal plane is gnored for two-axis tracking.
|
||||
if getattr(self, azimuth_attr, None) is not None:
|
||||
active_planes.append(f"pvforecast{i}")
|
||||
elif getattr(self, tilt_attr, None) and getattr(self, azimuth_attr, None):
|
||||
active_planes.append(f"pvforecast{i}")
|
||||
|
||||
return active_planes
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def pvforecast_planes_peakpower(self) -> List[float]:
|
||||
def planes_peakpower(self) -> List[float]:
|
||||
"""Compute a list of the peak power per active planes."""
|
||||
planes_peakpower = []
|
||||
|
||||
for plane in self.pvforecast_planes:
|
||||
peakpower_attr = f"{plane}_peakpower"
|
||||
peakpower = getattr(self, peakpower_attr, None)
|
||||
if peakpower is None:
|
||||
# TODO calculate peak power from modules/strings
|
||||
planes_peakpower.append(float(5000))
|
||||
else:
|
||||
planes_peakpower.append(float(peakpower))
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
peakpower = plane.peakpower
|
||||
if peakpower is None:
|
||||
# TODO calculate peak power from modules/strings
|
||||
planes_peakpower.append(float(5000))
|
||||
else:
|
||||
planes_peakpower.append(float(peakpower))
|
||||
|
||||
return planes_peakpower
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def pvforecast_planes_azimuth(self) -> List[float]:
|
||||
def planes_azimuth(self) -> List[float]:
|
||||
"""Compute a list of the azimuths per active planes."""
|
||||
planes_azimuth = []
|
||||
|
||||
for plane in self.pvforecast_planes:
|
||||
azimuth_attr = f"{plane}_surface_azimuth"
|
||||
azimuth = getattr(self, azimuth_attr, None)
|
||||
if azimuth is None:
|
||||
# TODO Use default
|
||||
planes_azimuth.append(float(180))
|
||||
else:
|
||||
planes_azimuth.append(float(azimuth))
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
azimuth = plane.surface_azimuth
|
||||
if azimuth is None:
|
||||
# TODO Use default
|
||||
planes_azimuth.append(float(180))
|
||||
else:
|
||||
planes_azimuth.append(float(azimuth))
|
||||
|
||||
return planes_azimuth
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def pvforecast_planes_tilt(self) -> List[float]:
|
||||
def planes_tilt(self) -> List[float]:
|
||||
"""Compute a list of the tilts per active planes."""
|
||||
planes_tilt = []
|
||||
|
||||
for plane in self.pvforecast_planes:
|
||||
tilt_attr = f"{plane}_surface_tilt"
|
||||
tilt = getattr(self, tilt_attr, None)
|
||||
if tilt is None:
|
||||
# TODO Use default
|
||||
planes_tilt.append(float(30))
|
||||
else:
|
||||
planes_tilt.append(float(tilt))
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
tilt = plane.surface_tilt
|
||||
if tilt is None:
|
||||
# TODO Use default
|
||||
planes_tilt.append(float(30))
|
||||
else:
|
||||
planes_tilt.append(float(tilt))
|
||||
|
||||
return planes_tilt
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def pvforecast_planes_userhorizon(self) -> Any:
|
||||
def planes_userhorizon(self) -> Any:
|
||||
"""Compute a list of the user horizon per active planes."""
|
||||
planes_userhorizon = []
|
||||
|
||||
for plane in self.pvforecast_planes:
|
||||
userhorizon_attr = f"{plane}_userhorizon"
|
||||
userhorizon = getattr(self, userhorizon_attr, None)
|
||||
if userhorizon is None:
|
||||
# TODO Use default
|
||||
planes_userhorizon.append([float(0), float(0)])
|
||||
else:
|
||||
planes_userhorizon.append(userhorizon)
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
userhorizon = plane.userhorizon
|
||||
if userhorizon is None:
|
||||
# TODO Use default
|
||||
planes_userhorizon.append([float(0), float(0)])
|
||||
else:
|
||||
planes_userhorizon.append(userhorizon)
|
||||
|
||||
return planes_userhorizon
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@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."""
|
||||
planes_inverter_paco = []
|
||||
|
||||
for plane in self.pvforecast_planes:
|
||||
inverter_paco_attr = f"{plane}_inverter_paco"
|
||||
inverter_paco = getattr(self, inverter_paco_attr, None)
|
||||
if inverter_paco is None:
|
||||
# TODO Use default - no clipping
|
||||
planes_inverter_paco.append(25000.0)
|
||||
else:
|
||||
planes_inverter_paco.append(float(inverter_paco))
|
||||
if self.planes:
|
||||
for plane in self.planes:
|
||||
inverter_paco = plane.inverter_paco
|
||||
if inverter_paco is None:
|
||||
# TODO Use default - no clipping
|
||||
planes_inverter_paco.append(25000.0)
|
||||
else:
|
||||
planes_inverter_paco.append(float(inverter_paco))
|
||||
|
||||
return planes_inverter_paco
|
||||
|
@@ -28,18 +28,18 @@ class PVForecastProvider(PredictionProvider):
|
||||
PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
|
||||
Configuration variables:
|
||||
pvforecast_provider (str): Prediction provider for pvforecast.
|
||||
provider (str): Prediction provider for pvforecast.
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions (inlcusive), defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range (exclusive),
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
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
|
||||
@@ -54,6 +54,6 @@ class PVForecastProvider(PredictionProvider):
|
||||
|
||||
def enabled(self) -> bool:
|
||||
logger.debug(
|
||||
f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast_provider}"
|
||||
f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.provider}"
|
||||
)
|
||||
return self.provider_id() == self.config.pvforecast_provider
|
||||
return self.provider_id() == self.config.pvforecast.provider
|
||||
|
@@ -14,21 +14,33 @@ Classes:
|
||||
Example:
|
||||
# Set up the configuration with necessary fields for URL generation
|
||||
settings_data = {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"pvforecast_provider": "Akkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Create the config instance from the provided data
|
||||
@@ -47,12 +59,12 @@ Example:
|
||||
print(forecast.report_ac_power_and_measurement())
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int): Number of hours into the future to forecast. Default is 48.
|
||||
prediction_historic_hours (int): Number of past hours to retain for analysis. Default is 24.
|
||||
hours (int): Number of hours into the future to forecast. Default is 48.
|
||||
historic_hours (int): Number of past hours to retain for analysis. Default is 24.
|
||||
latitude (float): Latitude for the forecast location.
|
||||
longitude (float): Longitude for the forecast location.
|
||||
start_datetime (datetime): Start time for the forecast, defaulting to current datetime.
|
||||
end_datetime (datetime): Computed end datetime based on `start_datetime` and `prediction_hours`.
|
||||
end_datetime (datetime): Computed end datetime based on `start_datetime` and `hours`.
|
||||
keep_datetime (datetime): Computed threshold datetime for retaining historical data.
|
||||
|
||||
Methods:
|
||||
@@ -159,13 +171,13 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int, optional): Number of hours in the future for the forecast.
|
||||
prediction_historic_hours (int, optional): Number of past hours for retaining data.
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
|
||||
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_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 `historic_hours`.
|
||||
|
||||
Methods:
|
||||
provider_id(): Returns a unique identifier for the provider.
|
||||
@@ -203,19 +215,19 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
"""Build akkudoktor.net API request URL."""
|
||||
base_url = "https://api.akkudoktor.net/forecast"
|
||||
query_params = [
|
||||
f"lat={self.config.latitude}",
|
||||
f"lon={self.config.longitude}",
|
||||
f"lat={self.config.general.latitude}",
|
||||
f"lon={self.config.general.longitude}",
|
||||
]
|
||||
|
||||
for i in range(len(self.config.pvforecast_planes)):
|
||||
query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}")
|
||||
query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}")
|
||||
query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}")
|
||||
for i in range(len(self.config.pvforecast.planes)):
|
||||
query_params.append(f"power={int(self.config.pvforecast.planes_peakpower[i] * 1000)}")
|
||||
query_params.append(f"azimuth={int(self.config.pvforecast.planes_azimuth[i])}")
|
||||
query_params.append(f"tilt={int(self.config.pvforecast.planes_tilt[i])}")
|
||||
query_params.append(
|
||||
f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}"
|
||||
f"powerInverter={int(self.config.pvforecast.planes_inverter_paco[i])}"
|
||||
)
|
||||
horizon_values = ",".join(
|
||||
str(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}")
|
||||
|
||||
@@ -226,7 +238,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
"cellCoEff=-0.36",
|
||||
"inverterEfficiency=0.8",
|
||||
"albedo=0.25",
|
||||
f"timezone={self.config.timezone}",
|
||||
f"timezone={self.config.general.timezone}",
|
||||
"hourly=relativehumidity_2m%2Cwindspeed_10m",
|
||||
]
|
||||
)
|
||||
@@ -255,7 +267,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
logger.debug(f"Response from {self._url()}: {response}")
|
||||
akkudoktor_data = self._validate_data(response.content)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return akkudoktor_data
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
@@ -265,7 +277,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
`PVForecastAkkudoktorDataRecord`.
|
||||
"""
|
||||
# Assure we have something to request PV power for.
|
||||
if not self.config.pvforecast_planes:
|
||||
if not self.config.pvforecast.planes:
|
||||
# No planes for PV
|
||||
error_msg = "Requested PV forecast, but no planes configured."
|
||||
logger.error(f"Configuration error: {error_msg}")
|
||||
@@ -275,17 +287,17 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
|
||||
|
||||
# Timezone of the PV system
|
||||
if self.config.timezone != akkudoktor_data.meta.timezone:
|
||||
error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
|
||||
if self.config.general.timezone != akkudoktor_data.meta.timezone:
|
||||
error_msg = f"Configured timezone '{self.config.general.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
|
||||
logger.error(f"Akkudoktor schema change: {error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
if len(akkudoktor_data.values[0]) < self.config.prediction_hours:
|
||||
if len(akkudoktor_data.values[0]) < self.config.prediction.hours:
|
||||
# Expect one value set per prediction hour
|
||||
error_msg = (
|
||||
f"The forecast must cover at least {self.config.prediction_hours} hours, "
|
||||
f"The forecast must cover at least {self.config.prediction.hours} hours, "
|
||||
f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data."
|
||||
)
|
||||
logger.error(f"Akkudoktor schema change: {error_msg}")
|
||||
@@ -296,7 +308,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
# Iterate over forecast data points
|
||||
for forecast_values in zip(*akkudoktor_data.values):
|
||||
original_datetime = forecast_values[0].datetime
|
||||
dt = to_datetime(original_datetime, in_timezone=self.config.timezone)
|
||||
dt = to_datetime(original_datetime, in_timezone=self.config.general.timezone)
|
||||
|
||||
# Skip outdated forecast data
|
||||
if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
|
||||
@@ -314,9 +326,9 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
|
||||
self.update_value(dt, data)
|
||||
|
||||
if len(self) < self.config.prediction_hours:
|
||||
if len(self) < self.config.prediction.hours:
|
||||
raise ValueError(
|
||||
f"The forecast must cover at least {self.config.prediction_hours} hours, "
|
||||
f"The forecast must cover at least {self.config.prediction.hours} hours, "
|
||||
f"but only {len(self)} hours starting from {self.start_datetime} "
|
||||
f"were predicted."
|
||||
)
|
||||
@@ -365,31 +377,47 @@ if __name__ == "__main__":
|
||||
"""
|
||||
# Set up the configuration with necessary fields for URL generation
|
||||
settings_data = {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Initialize the forecast object with the generated configuration
|
||||
|
@@ -22,21 +22,22 @@ logger = get_logger(__name__)
|
||||
class PVForecastImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for pvforecast data import from file or JSON string."""
|
||||
|
||||
pvforecastimport_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None, description="Path to the file to import PV forecast data from."
|
||||
import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None,
|
||||
description="Path to the file to import PV forecast data from.",
|
||||
examples=[None, "/path/to/pvforecast.json"],
|
||||
)
|
||||
|
||||
pvforecastimport_json: Optional[str] = Field(
|
||||
import_json: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JSON string, dictionary of PV forecast value lists.",
|
||||
examples=['{"pvforecast_ac_power": [0, 8.05, 352.91]}'],
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("pvforecastimport_file_path", mode="after")
|
||||
@field_validator("import_file_path", mode="after")
|
||||
@classmethod
|
||||
def validate_pvforecastimport_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:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
@@ -62,7 +63,13 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
|
||||
return "PVForecastImport"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
if self.config.pvforecastimport_file_path is not None:
|
||||
self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast")
|
||||
if self.config.pvforecastimport_json is not None:
|
||||
self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast")
|
||||
if self.config.pvforecast.provider_settings.import_file_path is not None:
|
||||
self.import_from_file(
|
||||
self.config.pvforecast.provider_settings.import_file_path,
|
||||
key_prefix="pvforecast",
|
||||
)
|
||||
if self.config.pvforecast.provider_settings.import_json is not None:
|
||||
self.import_from_json(
|
||||
self.config.pvforecast.provider_settings.import_json,
|
||||
key_prefix="pvforecast",
|
||||
)
|
||||
|
@@ -5,9 +5,18 @@ from typing import Optional
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
||||
|
||||
|
||||
class WeatherCommonSettings(SettingsBaseModel):
|
||||
weather_provider: Optional[str] = Field(
|
||||
default=None, description="Weather provider id of provider to be used."
|
||||
"""Weather Forecast Configuration."""
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Weather provider id of provider to be used.",
|
||||
examples=["WeatherImport"],
|
||||
)
|
||||
|
||||
provider_settings: Optional[WeatherImportCommonSettings] = Field(
|
||||
default=None, description="Provider settings", examples=[None]
|
||||
)
|
||||
|
@@ -101,18 +101,18 @@ class WeatherProvider(PredictionProvider):
|
||||
WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
|
||||
Configuration variables:
|
||||
weather_provider (str): Prediction provider for weather.
|
||||
provider (str): Prediction provider for weather.
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
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
|
||||
@@ -126,7 +126,7 @@ class WeatherProvider(PredictionProvider):
|
||||
return "WeatherProvider"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self.provider_id() == self.config.weather_provider
|
||||
return self.provider_id() == self.config.weather.provider
|
||||
|
||||
@classmethod
|
||||
def estimate_irradiance_from_cloud_cover(
|
||||
|
@@ -62,13 +62,13 @@ class WeatherBrightSky(WeatherProvider):
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int, optional): Number of hours in the future for the forecast.
|
||||
prediction_historic_hours (int, optional): Number of past hours for retaining data.
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
|
||||
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `prediction_hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `prediction_historic_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 `historic_hours`.
|
||||
|
||||
Methods:
|
||||
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")
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
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
|
||||
logger.debug(f"Response from {source}: {response}")
|
||||
@@ -109,7 +109,7 @@ class WeatherBrightSky(WeatherProvider):
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return brightsky_data
|
||||
|
||||
def _description_to_series(self, description: str) -> pd.Series:
|
||||
@@ -200,7 +200,7 @@ class WeatherBrightSky(WeatherProvider):
|
||||
description = "Total Clouds (% Sky Obscured)"
|
||||
cloud_cover = self._description_to_series(description)
|
||||
ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
|
||||
self.config.latitude, self.config.longitude, cloud_cover
|
||||
self.config.general.latitude, self.config.general.longitude, cloud_cover
|
||||
)
|
||||
|
||||
description = "Global Horizontal Irradiance (W/m2)"
|
||||
|
@@ -68,15 +68,15 @@ class WeatherClearOutside(WeatherProvider):
|
||||
WeatherClearOutside is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
prediction_historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
hours (int, optional): The number of hours into the future for which predictions are generated.
|
||||
historic_hours (int, optional): The number of past hours for which historical data is retained.
|
||||
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
|
||||
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
|
||||
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
|
||||
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
|
||||
calculated based on `start_datetime` and `prediction_hours`.
|
||||
calculated based on `start_datetime` and `hours`.
|
||||
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
|
||||
@@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider):
|
||||
response: Weather forecast request reponse from ClearOutside.
|
||||
"""
|
||||
source = "https://clearoutside.com/forecast"
|
||||
latitude = round(self.config.latitude, 2)
|
||||
longitude = round(self.config.longitude, 2)
|
||||
latitude = round(self.config.general.latitude, 2)
|
||||
longitude = round(self.config.general.longitude, 2)
|
||||
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
logger.debug(f"Response from {source}: {response}")
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return response
|
||||
|
||||
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"]
|
||||
)
|
||||
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
|
||||
|
@@ -22,18 +22,22 @@ logger = get_logger(__name__)
|
||||
class WeatherImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for weather data import from file or JSON string."""
|
||||
|
||||
weatherimport_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None, description="Path to the file to import weather data from."
|
||||
import_file_path: Optional[Union[str, Path]] = Field(
|
||||
default=None,
|
||||
description="Path to the file to import weather data from.",
|
||||
examples=[None, "/path/to/weather_data.json"],
|
||||
)
|
||||
|
||||
weatherimport_json: Optional[str] = Field(
|
||||
default=None, description="JSON string, dictionary of weather forecast value lists."
|
||||
import_json: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JSON string, dictionary of weather forecast value lists.",
|
||||
examples=['{"weather_temp_air": [18.3, 17.8, 16.9]}'],
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("weatherimport_file_path", mode="after")
|
||||
@field_validator("import_file_path", mode="after")
|
||||
@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:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
@@ -59,7 +63,11 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
|
||||
return "WeatherImport"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
if self.config.weatherimport_file_path is not None:
|
||||
self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather")
|
||||
if self.config.weatherimport_json is not None:
|
||||
self.import_from_json(self.config.weatherimport_json, key_prefix="weather")
|
||||
if self.config.weather.provider_settings.import_file_path:
|
||||
self.import_from_file(
|
||||
self.config.weather.provider_settings.import_file_path, key_prefix="weather"
|
||||
)
|
||||
if self.config.weather.provider_settings.import_json:
|
||||
self.import_from_json(
|
||||
self.config.weather.provider_settings.import_json, key_prefix="weather"
|
||||
)
|
||||
|
@@ -29,7 +29,11 @@ from akkudoktoreos.optimization.genetic import (
|
||||
OptimizeResponse,
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -149,16 +153,16 @@ def start_eosdash() -> subprocess.Popen:
|
||||
|
||||
if args is None:
|
||||
# No command line arguments
|
||||
host = config_eos.server_eosdash_host
|
||||
port = config_eos.server_eosdash_port
|
||||
eos_host = config_eos.server_eos_host
|
||||
eos_port = config_eos.server_eos_port
|
||||
host = config_eos.server.eosdash_host
|
||||
port = config_eos.server.eosdash_port
|
||||
eos_host = config_eos.server.host
|
||||
eos_port = config_eos.server.port
|
||||
log_level = "info"
|
||||
access_log = False
|
||||
reload = False
|
||||
else:
|
||||
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_port = args.port
|
||||
log_level = args.log_level
|
||||
@@ -201,7 +205,7 @@ def start_eosdash() -> subprocess.Popen:
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Lifespan manager for the app."""
|
||||
# On startup
|
||||
if config_eos.server_eos_startup_eosdash:
|
||||
if config_eos.server.startup_eosdash:
|
||||
try:
|
||||
eosdash_process = start_eosdash()
|
||||
except Exception as e:
|
||||
@@ -226,10 +230,6 @@ app = FastAPI(
|
||||
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()
|
||||
|
||||
|
||||
@@ -237,66 +237,24 @@ class PdfResponse(FileResponse):
|
||||
media_type = "application/pdf"
|
||||
|
||||
|
||||
@app.put("/v1/config/value")
|
||||
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")
|
||||
@app.put("/v1/config/reset", tags=["config"])
|
||||
def fastapi_config_update_post() -> ConfigEOS:
|
||||
"""Update the configuration from the EOS configuration file.
|
||||
"""Reset the configuration to the EOS configuration file.
|
||||
|
||||
Returns:
|
||||
configuration (ConfigEOS): The current configuration after update.
|
||||
"""
|
||||
try:
|
||||
_, config_file_path = config_eos.from_config_file()
|
||||
except:
|
||||
config_eos.reset_settings()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
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
|
||||
|
||||
|
||||
@app.get("/v1/config/file")
|
||||
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")
|
||||
@app.put("/v1/config/file", tags=["config"])
|
||||
def fastapi_config_file_put() -> ConfigEOS:
|
||||
"""Save the current configuration to the EOS configuration file.
|
||||
|
||||
@@ -313,7 +271,7 @@ def fastapi_config_file_put() -> ConfigEOS:
|
||||
return config_eos
|
||||
|
||||
|
||||
@app.get("/v1/config")
|
||||
@app.get("/v1/config", tags=["config"])
|
||||
def fastapi_config_get() -> ConfigEOS:
|
||||
"""Get the current configuration.
|
||||
|
||||
@@ -323,15 +281,13 @@ def fastapi_config_get() -> ConfigEOS:
|
||||
return config_eos
|
||||
|
||||
|
||||
@app.put("/v1/config")
|
||||
def fastapi_config_put(
|
||||
settings: Annotated[SettingsEOS, Query(description="settings")],
|
||||
) -> ConfigEOS:
|
||||
"""Write the provided settings into the current settings.
|
||||
@app.put("/v1/config", tags=["config"])
|
||||
def fastapi_config_put(settings: SettingsEOS) -> ConfigEOS:
|
||||
"""Update the current config with the provided settings.
|
||||
|
||||
The existing settings are completely overwritten. Note that for any setting
|
||||
value that is None, the configuration will fall back to values from other sources such as
|
||||
environment variables, the EOS configuration file, or default values.
|
||||
Note that for any setting value that is None or unset, the configuration will fall back to
|
||||
values from other sources such as environment variables, the EOS configuration file, or default
|
||||
values.
|
||||
|
||||
Args:
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
config_eos.merge_settings(settings, force=True)
|
||||
config_eos.merge_settings(settings)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}")
|
||||
return config_eos
|
||||
|
||||
|
||||
@app.get("/v1/measurement/keys")
|
||||
@app.get("/v1/measurement/keys", tags=["measurement"])
|
||||
def fastapi_measurement_keys_get() -> list[str]:
|
||||
"""Get a list of available measurement 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(
|
||||
name: Annotated[str, Query(description="Load name.")],
|
||||
) -> PydanticDateTimeSeries:
|
||||
@@ -368,7 +324,7 @@ def fastapi_measurement_load_mr_series_by_name_get(
|
||||
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(
|
||||
datetime: Annotated[str, Query(description="Datetime.")],
|
||||
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)
|
||||
|
||||
|
||||
@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(
|
||||
name: Annotated[str, Query(description="Load name.")], series: PydanticDateTimeSeries
|
||||
) -> PydanticDateTimeSeries:
|
||||
@@ -405,7 +361,7 @@ def fastapi_measurement_load_mr_series_by_name_put(
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
|
||||
|
||||
@app.get("/v1/measurement/series")
|
||||
@app.get("/v1/measurement/series", tags=["measurement"])
|
||||
def fastapi_measurement_series_get(
|
||||
key: Annotated[str, Query(description="Prediction key.")],
|
||||
) -> PydanticDateTimeSeries:
|
||||
@@ -416,7 +372,7 @@ def fastapi_measurement_series_get(
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/value")
|
||||
@app.put("/v1/measurement/value", tags=["measurement"])
|
||||
def fastapi_measurement_value_put(
|
||||
datetime: Annotated[str, Query(description="Datetime.")],
|
||||
key: Annotated[str, Query(description="Prediction key.")],
|
||||
@@ -430,7 +386,7 @@ def fastapi_measurement_value_put(
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/series")
|
||||
@app.put("/v1/measurement/series", tags=["measurement"])
|
||||
def fastapi_measurement_series_put(
|
||||
key: Annotated[str, Query(description="Prediction key.")], series: PydanticDateTimeSeries
|
||||
) -> PydanticDateTimeSeries:
|
||||
@@ -443,27 +399,47 @@ def fastapi_measurement_series_put(
|
||||
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:
|
||||
"""Merge the measurement data given as dataframe into EOS measurements."""
|
||||
dataframe = data.to_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:
|
||||
"""Merge the measurement data given as datetime data into EOS measurements."""
|
||||
datetimedata = data.to_dict()
|
||||
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]:
|
||||
"""Get a list of available prediction keys."""
|
||||
return sorted(prediction_eos.record_keys)
|
||||
|
||||
|
||||
@app.get("/v1/prediction/series")
|
||||
@app.get("/v1/prediction/series", tags=["prediction"])
|
||||
def fastapi_prediction_series_get(
|
||||
key: Annotated[str, Query(description="Prediction key.")],
|
||||
start_datetime: Annotated[
|
||||
@@ -500,7 +476,7 @@ def fastapi_prediction_series_get(
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
|
||||
|
||||
@app.get("/v1/prediction/list")
|
||||
@app.get("/v1/prediction/list", tags=["prediction"])
|
||||
def fastapi_prediction_list_get(
|
||||
key: Annotated[str, Query(description="Prediction key.")],
|
||||
start_datetime: Annotated[
|
||||
@@ -550,7 +526,7 @@ def fastapi_prediction_list_get(
|
||||
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:
|
||||
"""Update predictions for all providers.
|
||||
|
||||
@@ -563,11 +539,12 @@ def fastapi_prediction_update(force_update: bool = False, force_enable: bool = F
|
||||
try:
|
||||
prediction_eos.update_data(force_update=force_update, force_enable=force_enable)
|
||||
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()
|
||||
|
||||
|
||||
@app.post("/v1/prediction/update/{provider_id}")
|
||||
@app.post("/v1/prediction/update/{provider_id}", tags=["prediction"])
|
||||
def fastapi_prediction_update_provider(
|
||||
provider_id: str, force_update: Optional[bool] = False, force_enable: Optional[bool] = False
|
||||
) -> Response:
|
||||
@@ -591,7 +568,7 @@ def fastapi_prediction_update_provider(
|
||||
return Response()
|
||||
|
||||
|
||||
@app.get("/strompreis")
|
||||
@app.get("/strompreis", tags=["prediction"])
|
||||
def fastapi_strompreis() -> list[float]:
|
||||
"""Deprecated: Electricity Market Price Prediction per Wh (€/Wh).
|
||||
|
||||
@@ -603,14 +580,16 @@ def fastapi_strompreis() -> list[float]:
|
||||
Electricity price charges are added.
|
||||
|
||||
Note:
|
||||
Set ElecPriceAkkudoktor as elecprice_provider, then update data with
|
||||
Set ElecPriceAkkudoktor as provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=elecprice_marketprice_wh' or
|
||||
'/v1/prediction/list?key=elecprice_marketprice_kwh' instead.
|
||||
"""
|
||||
settings = SettingsEOS(
|
||||
elecprice_provider="ElecPriceAkkudoktor",
|
||||
elecprice=ElecPriceCommonSettings(
|
||||
provider="ElecPriceAkkudoktor",
|
||||
)
|
||||
)
|
||||
config_eos.merge_settings(settings=settings)
|
||||
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
|
||||
@@ -643,7 +622,7 @@ class GesamtlastRequest(PydanticBaseModel):
|
||||
hours: int
|
||||
|
||||
|
||||
@app.post("/gesamtlast")
|
||||
@app.post("/gesamtlast", tags=["prediction"])
|
||||
def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
|
||||
"""Deprecated: Total Load Prediction with adjustment.
|
||||
|
||||
@@ -660,16 +639,22 @@ def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
|
||||
'/v1/measurement/value'
|
||||
"""
|
||||
settings = SettingsEOS(
|
||||
prediction_hours=request.hours,
|
||||
load_provider="LoadAkkudoktor",
|
||||
loadakkudoktor_year_energy=request.year_energy,
|
||||
prediction=PredictionCommonSettings(
|
||||
hours=request.hours,
|
||||
),
|
||||
load=LoadCommonSettings(
|
||||
provider="LoadAkkudoktor",
|
||||
provider_settings=LoadAkkudoktorCommonSettings(
|
||||
loadakkudoktor_year_energy=request.year_energy,
|
||||
),
|
||||
),
|
||||
)
|
||||
config_eos.merge_settings(settings=settings)
|
||||
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
|
||||
|
||||
# Insert measured data into EOS measurement
|
||||
# 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
|
||||
energy = {}
|
||||
try:
|
||||
@@ -718,7 +703,7 @@ def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
|
||||
return prediction_list
|
||||
|
||||
|
||||
@app.get("/gesamtlast_simple")
|
||||
@app.get("/gesamtlast_simple", tags=["prediction"])
|
||||
def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
|
||||
"""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.
|
||||
|
||||
Note:
|
||||
Set LoadAkkudoktor as load_provider, then update data with
|
||||
Set LoadAkkudoktor as provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=load_mean' instead.
|
||||
"""
|
||||
settings = SettingsEOS(
|
||||
load_provider="LoadAkkudoktor",
|
||||
loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh
|
||||
load=LoadCommonSettings(
|
||||
provider="LoadAkkudoktor",
|
||||
provider_settings=LoadAkkudoktorCommonSettings(
|
||||
loadakkudoktor_year_energy=year_energy / 1000, # Convert to kWh
|
||||
),
|
||||
)
|
||||
)
|
||||
config_eos.merge_settings(settings=settings)
|
||||
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
|
||||
@@ -770,7 +759,7 @@ class ForecastResponse(PydanticBaseModel):
|
||||
pvpower: list[float]
|
||||
|
||||
|
||||
@app.get("/pvforecast")
|
||||
@app.get("/pvforecast", tags=["prediction"])
|
||||
def fastapi_pvforecast() -> ForecastResponse:
|
||||
"""Deprecated: PV Forecast Prediction.
|
||||
|
||||
@@ -781,21 +770,25 @@ def fastapi_pvforecast() -> ForecastResponse:
|
||||
filled with the first available forecast value.
|
||||
|
||||
Note:
|
||||
Set PVForecastAkkudoktor as pvforecast_provider, then update data with
|
||||
Set PVForecastAkkudoktor as provider, then update data with
|
||||
'/v1/prediction/update'
|
||||
and then request data with
|
||||
'/v1/prediction/list?key=pvforecast_ac_power' and
|
||||
'/v1/prediction/list?key=pvforecastakkudoktor_temp_air' instead.
|
||||
"""
|
||||
settings = SettingsEOS(
|
||||
elecprice_provider="PVForecastAkkudoktor",
|
||||
)
|
||||
settings = SettingsEOS(pvforecast=PVForecastCommonSettings(provider="PVForecastAkkudoktor"))
|
||||
config_eos.merge_settings(settings=settings)
|
||||
|
||||
ems_eos.set_start_datetime() # Set energy management start datetime to current hour.
|
||||
|
||||
# Create PV forecast
|
||||
prediction_eos.update_data(force_update=True)
|
||||
try:
|
||||
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
|
||||
start_datetime = to_datetime().start_of("day")
|
||||
@@ -821,30 +814,35 @@ def fastapi_pvforecast() -> ForecastResponse:
|
||||
return ForecastResponse(temperature=temp_air, pvpower=ac_power)
|
||||
|
||||
|
||||
@app.post("/optimize")
|
||||
@app.post("/optimize", tags=["optimize"])
|
||||
def fastapi_optimize(
|
||||
parameters: OptimizationParameters,
|
||||
start_hour: Annotated[
|
||||
Optional[int], Query(description="Defaults to current hour of the day.")
|
||||
] = None,
|
||||
ngen: Optional[int] = None,
|
||||
) -> OptimizeResponse:
|
||||
if start_hour is None:
|
||||
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.
|
||||
config_eos.update()
|
||||
prediction_eos.update_data()
|
||||
|
||||
# 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)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/visualization_results.pdf", response_class=PdfResponse)
|
||||
@app.get("/visualization_results.pdf", response_class=PdfResponse, tags=["optimize"])
|
||||
def get_pdf() -> PdfResponse:
|
||||
# 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():
|
||||
raise HTTPException(status_code=404, detail=f"Output path does not exist: {output_path}.")
|
||||
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.
|
||||
|
||||
if config_eos.server.startup_eosdash:
|
||||
|
||||
@app.delete("/{path:path}", include_in_schema=False)
|
||||
async def proxy_delete(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
@app.delete("/{path:path}", include_in_schema=False)
|
||||
async def proxy_delete(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
async def proxy_get(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
async def proxy_get(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
@app.post("/{path:path}", include_in_schema=False)
|
||||
async def proxy_post(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
|
||||
@app.put("/{path:path}", include_in_schema=False)
|
||||
async def proxy_put(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
else:
|
||||
|
||||
@app.post("/{path:path}", include_in_schema=False)
|
||||
async def proxy_post(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
|
||||
|
||||
@app.put("/{path:path}", include_in_schema=False)
|
||||
async def proxy_put(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root() -> RedirectResponse:
|
||||
return RedirectResponse(url="/docs")
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
data = await request.body()
|
||||
@@ -906,9 +907,9 @@ async def proxy(request: Request, path: str) -> Union[Response | RedirectRespons
|
||||
error_message=f"""<pre>
|
||||
EOSdash server not reachable: '{url}'
|
||||
Did you start the EOSdash server
|
||||
or set 'server_eos_startup_eosdash'?
|
||||
or set 'startup_eosdash'?
|
||||
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>
|
||||
""",
|
||||
error_details=f"{e}",
|
||||
@@ -972,8 +973,8 @@ def main() -> None:
|
||||
it starts the EOS server with the specified configurations.
|
||||
|
||||
Command-line Arguments:
|
||||
--host (str): Host for the EOS server (default: value from config_eos).
|
||||
--port (int): Port 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).
|
||||
--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).
|
||||
--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(
|
||||
"--host",
|
||||
type=str,
|
||||
default=str(config_eos.server_eos_host),
|
||||
help="Host for the EOS server (default: value from config_eos)",
|
||||
default=str(config_eos.server.host),
|
||||
help="Host for the EOS server (default: value from config)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=config_eos.server_eos_port,
|
||||
help="Port for the EOS server (default: value from config_eos)",
|
||||
default=config_eos.server.port,
|
||||
help="Port for the EOS server (default: value from config)",
|
||||
)
|
||||
|
||||
# Optional arguments for log_level, access_log, and reload
|
||||
@@ -1019,7 +1020,7 @@ def main() -> None:
|
||||
try:
|
||||
run_eos(args.host, args.port, args.log_level, args.access_log, args.reload)
|
||||
except:
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@@ -1,11 +1,17 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from functools import reduce
|
||||
from typing import Any, Union
|
||||
|
||||
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.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -14,18 +20,84 @@ config_eos = get_config()
|
||||
# Command line arguments
|
||||
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 = []
|
||||
for field_name in config_eos.model_fields:
|
||||
config = {}
|
||||
config["name"] = field_name
|
||||
config["value"] = getattr(config_eos, field_name)
|
||||
config["default"] = config_eos.model_fields[field_name].default
|
||||
config["description"] = config_eos.model_fields[field_name].description
|
||||
configs.append(config)
|
||||
inner_types: set[type[PydanticBaseModel]] = set()
|
||||
for field_name, field_info in list(config_eos.model_fields.items()) + list(
|
||||
config_eos.model_computed_fields.items()
|
||||
):
|
||||
|
||||
def extract_nested_models(
|
||||
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
|
||||
) -> None:
|
||||
regular_field = isinstance(subfield_info, FieldInfo)
|
||||
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
|
||||
|
||||
if subtype in inner_types:
|
||||
return
|
||||
|
||||
nested_types = resolve_nested_types(subtype, [])
|
||||
found_basic = False
|
||||
for nested_type, nested_parent_types in nested_types:
|
||||
if not isinstance(nested_type, type) or not issubclass(nested_type, PydanticBaseModel):
|
||||
if found_basic:
|
||||
continue
|
||||
|
||||
config = {}
|
||||
config["name"] = ".".join(parent_types)
|
||||
try:
|
||||
config["value"] = reduce(getattr, [config_eos] + parent_types)
|
||||
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)
|
||||
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()
|
||||
rt = app.route
|
||||
app, rt = fast_app(
|
||||
secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"),
|
||||
)
|
||||
|
||||
|
||||
def config_table() -> Table:
|
||||
@@ -96,10 +168,10 @@ def main() -> None:
|
||||
it starts the EOSdash server with the specified configurations.
|
||||
|
||||
Command-line Arguments:
|
||||
--host (str): Host for the EOSdash server (default: value from config_eos).
|
||||
--port (int): Port for the EOSdash server (default: value from config_eos).
|
||||
--eos-host (str): Host for the EOS server (default: value from config_eos).
|
||||
--eos-port (int): Port for the EOS 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-host (str): Host 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").
|
||||
--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).
|
||||
@@ -110,28 +182,28 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
default=str(config_eos.server_eosdash_host),
|
||||
help="Host for the EOSdash server (default: value from config_eos)",
|
||||
default=str(config_eos.server.eosdash_host),
|
||||
help="Host for the EOSdash server (default: value from config)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=config_eos.server_eosdash_port,
|
||||
help="Port for the EOSdash server (default: value from config_eos)",
|
||||
default=config_eos.server.eosdash_port,
|
||||
help="Port for the EOSdash server (default: value from config)",
|
||||
)
|
||||
|
||||
# EOS Host and port arguments with defaults from config_eos
|
||||
parser.add_argument(
|
||||
"--eos-host",
|
||||
type=str,
|
||||
default=str(config_eos.server_eos_host),
|
||||
help="Host for the EOS server (default: value from config_eos)",
|
||||
default=str(config_eos.server.host),
|
||||
help="Host for the EOS server (default: value from config)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--eos-port",
|
||||
type=int,
|
||||
default=config_eos.server_eos_port,
|
||||
help="Port for the EOS server (default: value from config_eos)",
|
||||
default=config_eos.server.port,
|
||||
help="Port for the EOS server (default: value from config)",
|
||||
)
|
||||
|
||||
# Optional arguments for log_level, access_log, and reload
|
||||
@@ -159,7 +231,7 @@ def main() -> None:
|
||||
try:
|
||||
run_eosdash(args.host, args.port, args.log_level, args.access_log, args.reload)
|
||||
except:
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@@ -11,28 +11,24 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ServerCommonSettings(SettingsBaseModel):
|
||||
"""Common server settings.
|
||||
"""Server Configuration.
|
||||
|
||||
Attributes:
|
||||
To be added
|
||||
"""
|
||||
|
||||
server_eos_host: Optional[IPvAnyAddress] = Field(
|
||||
default="0.0.0.0", description="EOS server IP address."
|
||||
)
|
||||
server_eos_port: Optional[int] = Field(default=8503, description="EOS server IP port number.")
|
||||
server_eos_verbose: Optional[bool] = Field(default=False, description="Enable debug output")
|
||||
server_eos_startup_eosdash: Optional[bool] = Field(
|
||||
host: Optional[IPvAnyAddress] = Field(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")
|
||||
startup_eosdash: Optional[bool] = Field(
|
||||
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."
|
||||
)
|
||||
server_eosdash_port: Optional[int] = Field(
|
||||
default=8504, description="EOSdash server IP port number."
|
||||
)
|
||||
eosdash_port: Optional[int] = Field(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]:
|
||||
if value is not None and not (1024 <= value <= 49151):
|
||||
raise ValueError("Server port number must be between 1024 and 49151.")
|
||||
|
@@ -329,9 +329,9 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
|
||||
# File already available
|
||||
cache_file_obj = cache_item.cache_file
|
||||
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(
|
||||
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(
|
||||
cache_file=cache_file_obj,
|
||||
|
42
src/akkudoktoreos/utils/docs.py
Normal file
42
src/akkudoktoreos/utils/docs.py
Normal 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
|
@@ -10,6 +10,8 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UtilsCommonSettings(SettingsBaseModel):
|
||||
"""Utils Configuration."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -47,6 +49,6 @@ class NumpyEncoder(json.JSONEncoder):
|
||||
# # Example usage
|
||||
# start_date = datetime.datetime(2024, 3, 31) # Date of the DST change
|
||||
# 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:
|
||||
# prediction_hours = 24 # Default value for days without DST change
|
||||
# hours = 24 # Default value for days without DST change
|
||||
|
@@ -24,7 +24,12 @@ matplotlib.use(
|
||||
|
||||
|
||||
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
|
||||
self.filename = filename
|
||||
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.version = version # overwrite version as test for constant output of pdf for test
|
||||
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:
|
||||
"""Add a chart function to the current group."""
|
||||
def add_chart_to_group(self, chart_func: Callable[[], None], title: str | None) -> None:
|
||||
"""Add a chart function to the current group and save it as a PNG and SVG."""
|
||||
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:
|
||||
"""Finalize the current group and prepare for a new group."""
|
||||
@@ -51,7 +67,7 @@ class VisualizationReport(ConfigMixin):
|
||||
|
||||
def _initialize_pdf(self) -> None:
|
||||
"""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 os.path.isabs(self.filename):
|
||||
@@ -173,7 +189,7 @@ class VisualizationReport(ConfigMixin):
|
||||
plt.grid(True)
|
||||
|
||||
# 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]:
|
||||
plt.axvline(current_time, color="r", linestyle="--", label="Now")
|
||||
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
|
||||
# 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(
|
||||
self,
|
||||
@@ -256,7 +272,7 @@ class VisualizationReport(ConfigMixin):
|
||||
plt.grid(True) # Show grid
|
||||
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(
|
||||
self,
|
||||
@@ -278,7 +294,7 @@ class VisualizationReport(ConfigMixin):
|
||||
plt.colorbar(scatter, label="Constraint") # Add colorbar if color data is provided
|
||||
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(
|
||||
self,
|
||||
@@ -328,7 +344,7 @@ class VisualizationReport(ConfigMixin):
|
||||
plt.grid(True, zorder=0) # Show grid in the background
|
||||
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(
|
||||
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.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:
|
||||
"""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
|
||||
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(
|
||||
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
|
||||
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:
|
||||
"""Generate the PDF report with all the added chart groups."""
|
||||
@@ -419,7 +435,7 @@ def prepare_visualize(
|
||||
start_hour: Optional[int] = 0,
|
||||
) -> None:
|
||||
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:
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date, # start_date
|
||||
@@ -503,7 +519,7 @@ def prepare_visualize(
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date, # start_date
|
||||
[parameters.ems.strompreis_euro_pro_wh],
|
||||
# title="Electricity Price", # not enough space
|
||||
title="Electricity Price",
|
||||
# xlabel="Date", # not enough space
|
||||
ylabel="Electricity Price (€/Wh)",
|
||||
x2label=None, # not enough space
|
||||
@@ -538,7 +554,7 @@ def prepare_visualize(
|
||||
report.create_scatter_plot(
|
||||
extra_data["verluste"],
|
||||
extra_data["bilanz"],
|
||||
title="",
|
||||
title="Scatter Plot",
|
||||
xlabel="losses",
|
||||
ylabel="balance",
|
||||
c=extra_data["nebenbedingung"],
|
||||
|
@@ -64,6 +64,25 @@ def config_mixin(config_eos):
|
||||
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
|
||||
# Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json)
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -114,20 +133,24 @@ def config_eos(
|
||||
monkeypatch,
|
||||
) -> ConfigEOS:
|
||||
"""Fixture to reset EOS config to default values."""
|
||||
monkeypatch.setenv("data_cache_subpath", str(config_default_dirs[-1] / "data/cache"))
|
||||
monkeypatch.setenv("data_output_subpath", str(config_default_dirs[-1] / "data/output"))
|
||||
monkeypatch.setenv(
|
||||
"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_cwd = config_default_dirs[1] / ConfigEOS.CONFIG_FILE_NAME
|
||||
assert not config_file.exists()
|
||||
assert not config_file_cwd.exists()
|
||||
config_eos = get_config()
|
||||
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 not config_file_cwd.exists()
|
||||
assert config_default_dirs[-1] / "data" == config_eos.data_folder_path
|
||||
assert config_default_dirs[-1] / "data/cache" == config_eos.data_cache_path
|
||||
assert config_default_dirs[-1] / "data/output" == config_eos.data_output_path
|
||||
assert config_default_dirs[-1] / "data" == config_eos.general.data_folder_path
|
||||
assert config_default_dirs[-1] / "data/cache" == config_eos.general.data_cache_path
|
||||
assert config_default_dirs[-1] / "data/output" == config_eos.general.data_output_path
|
||||
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
|
||||
env = os.environ.copy()
|
||||
env["EOS_DIR"] = str(config_default_dirs[-1])
|
||||
project_dir = config_eos.package_root_path
|
||||
|
||||
# assure server to be installed
|
||||
try:
|
||||
@@ -175,9 +199,9 @@ def server(xprocess, config_eos, config_default_dirs):
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=project_dir,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
project_dir = config_eos.package_root_path
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-e", project_dir],
|
||||
check=True,
|
||||
|
@@ -7,13 +7,15 @@ from akkudoktoreos.devices.battery import Battery, SolarPanelBatteryParameters
|
||||
@pytest.fixture
|
||||
def setup_pv_battery():
|
||||
params = SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=10000,
|
||||
initial_soc_percentage=50,
|
||||
min_soc_percentage=20,
|
||||
max_soc_percentage=80,
|
||||
max_charge_power_w=8000,
|
||||
hours=24,
|
||||
)
|
||||
battery = Battery(params, hours=24)
|
||||
battery = Battery(params)
|
||||
battery.reset()
|
||||
return battery
|
||||
|
||||
@@ -113,7 +115,6 @@ def test_soc_limits(setup_pv_battery):
|
||||
|
||||
def test_max_charge_power_w(setup_pv_battery):
|
||||
battery = setup_pv_battery
|
||||
battery.setup()
|
||||
assert (
|
||||
battery.parameters.max_charge_power_w == 8000
|
||||
), "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):
|
||||
battery = setup_pv_battery
|
||||
battery.setup()
|
||||
initial_soc_wh = battery.soc_wh
|
||||
|
||||
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):
|
||||
battery = setup_pv_battery
|
||||
battery.setup()
|
||||
initial_soc_wh = battery.soc_wh
|
||||
|
||||
# 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):
|
||||
battery = setup_pv_battery
|
||||
battery.setup()
|
||||
|
||||
# Disable charging for all 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):
|
||||
battery = setup_pv_battery
|
||||
battery.setup()
|
||||
|
||||
relative_power = 0.5 # 50% of max charge 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
|
||||
|
||||
params = ElectricVehicleParameters(
|
||||
device_id="ev1",
|
||||
capacity_wh=40000,
|
||||
initial_soc_percentage=60,
|
||||
min_soc_percentage=10,
|
||||
max_soc_percentage=90,
|
||||
max_charge_power_w=7000,
|
||||
hours=24,
|
||||
)
|
||||
battery = Battery(params, hours=24)
|
||||
battery = Battery(params)
|
||||
battery.reset()
|
||||
return battery
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
@@ -16,58 +14,58 @@ from akkudoktoreos.devices.battery import (
|
||||
)
|
||||
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
||||
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
||||
|
||||
start_hour = 1
|
||||
|
||||
|
||||
# Example initialization of necessary components
|
||||
@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."""
|
||||
# Assure configuration holds the correct values
|
||||
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
|
||||
assert config_eos.prediction_hours is not None
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
||||
)
|
||||
assert config_eos.prediction.hours == 48
|
||||
|
||||
# Initialize the battery and the inverter
|
||||
akku = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
|
||||
),
|
||||
hours=config_eos.prediction_hours,
|
||||
device_id="battery1",
|
||||
capacity_wh=5000,
|
||||
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()
|
||||
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)
|
||||
home_appliance = HomeAppliance(
|
||||
HomeApplianceParameters(
|
||||
device_id="dishwasher1",
|
||||
consumption_wh=2000,
|
||||
duration_h=2,
|
||||
),
|
||||
hours=config_eos.prediction_hours,
|
||||
)
|
||||
home_appliance.set_starting_time(2)
|
||||
devices_eos.add_device(home_appliance)
|
||||
|
||||
# Example initialization of electric car battery
|
||||
eauto = Battery(
|
||||
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
|
||||
pv_prognose_wh = [
|
||||
|
@@ -1,11 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.core.ems import (
|
||||
EnergieManagementSystem,
|
||||
EnergieManagementSystemParameters,
|
||||
SimulationResult,
|
||||
get_ems,
|
||||
)
|
||||
from akkudoktoreos.devices.battery import (
|
||||
@@ -15,64 +14,61 @@ from akkudoktoreos.devices.battery import (
|
||||
)
|
||||
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
||||
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator
|
||||
|
||||
start_hour = 0
|
||||
|
||||
|
||||
# Example initialization of necessary components
|
||||
@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."""
|
||||
# Assure configuration holds the correct values
|
||||
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
|
||||
assert config_eos.prediction_hours is not None
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
||||
)
|
||||
assert config_eos.prediction.hours == 48
|
||||
|
||||
# Initialize the battery and the inverter
|
||||
akku = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
capacity_wh=5000, initial_soc_percentage=80, min_soc_percentage=10
|
||||
),
|
||||
hours=config_eos.prediction_hours,
|
||||
device_id="pv1", capacity_wh=5000, 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()
|
||||
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)
|
||||
home_appliance = HomeAppliance(
|
||||
HomeApplianceParameters(
|
||||
device_id="dishwasher1",
|
||||
consumption_wh=2000,
|
||||
duration_h=2,
|
||||
),
|
||||
hours=config_eos.prediction_hours,
|
||||
)
|
||||
)
|
||||
home_appliance.set_starting_time(2)
|
||||
devices_eos.add_device(home_appliance)
|
||||
|
||||
# Example initialization of electric car battery
|
||||
eauto = Battery(
|
||||
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
|
||||
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[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[11:15] = [0.00005] * 4
|
||||
strompreis_euro_pro_wh[20] = 0.00001
|
||||
@@ -146,10 +142,10 @@ def create_ems_instance(config_eos) -> EnergieManagementSystem:
|
||||
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
|
||||
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
|
||||
ems.set_akku_dc_charge_hours(dc)
|
||||
|
||||
@@ -182,6 +178,7 @@ def test_simulation(create_ems_instance):
|
||||
# Assertions to validate results
|
||||
assert result is not None, "Result should not be None"
|
||||
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'"
|
||||
|
||||
"""
|
||||
@@ -240,7 +237,7 @@ def test_simulation(create_ems_instance):
|
||||
|
||||
assert (
|
||||
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 (
|
||||
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."
|
||||
assert (
|
||||
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.")
|
||||
|
||||
|
||||
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'"
|
||||
|
@@ -49,7 +49,9 @@ def test_optimize(
|
||||
):
|
||||
"""Test optimierung_ems."""
|
||||
# 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
|
||||
file = DIR_TESTDATA / fn_in
|
||||
|
@@ -3,8 +3,9 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -38,22 +39,26 @@ def test_config_constants(config_eos):
|
||||
|
||||
def test_computed_paths(config_eos):
|
||||
"""Test computed paths for output and cache."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"data_folder_path": "/base/data",
|
||||
"data_output_subpath": "output",
|
||||
"data_cache_subpath": "cache",
|
||||
}
|
||||
)
|
||||
assert config_eos.data_output_path == Path("/base/data/output")
|
||||
assert config_eos.data_cache_path == Path("/base/data/cache")
|
||||
# Don't actually try to create the data folder
|
||||
with patch("pathlib.Path.mkdir"):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"general": {
|
||||
"data_folder_path": "/base/data",
|
||||
"data_output_subpath": "extra/output",
|
||||
"data_cache_subpath": "somewhere/cache",
|
||||
}
|
||||
}
|
||||
)
|
||||
assert config_eos.general.data_output_path == Path("/base/data/extra/output")
|
||||
assert config_eos.general.data_cache_path == Path("/base/data/somewhere/cache")
|
||||
# reset settings so the config_eos fixture can verify the default paths
|
||||
config_eos.reset_settings()
|
||||
|
||||
|
||||
def test_singleton_behavior(config_eos, config_default_dirs):
|
||||
"""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(
|
||||
"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()
|
||||
assert instance1 is config_eos
|
||||
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):
|
||||
@@ -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.write_text("{}")
|
||||
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.parent.mkdir()
|
||||
config_file.write_text("{}")
|
||||
config_eos = get_config()
|
||||
assert config_eos.config_file_path == config_file
|
||||
config_eos.update()
|
||||
assert config_eos.general.config_file_path == config_file
|
||||
|
||||
|
||||
@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()
|
||||
with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir):
|
||||
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()
|
||||
|
||||
|
||||
@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
|
||||
|
@@ -535,7 +535,7 @@ class TestDataSequence:
|
||||
json_str = sequence.to_json()
|
||||
assert isinstance(json_str, 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):
|
||||
json_str = sequence2.to_json()
|
||||
|
@@ -86,7 +86,7 @@ def test_config_md_current(config_eos):
|
||||
sys.path.insert(0, str(root_dir))
|
||||
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:
|
||||
f_new.write(config_md)
|
||||
|
@@ -23,9 +23,10 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def elecprice_provider(monkeypatch):
|
||||
def provider(monkeypatch, config_eos):
|
||||
"""Fixture to create a ElecPriceProvider instance."""
|
||||
monkeypatch.setenv("elecprice_provider", "ElecPriceAkkudoktor")
|
||||
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceAkkudoktor")
|
||||
config_eos.reset_settings()
|
||||
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."""
|
||||
another_instance = ElecPriceAkkudoktor()
|
||||
assert elecprice_provider is another_instance
|
||||
assert provider is another_instance
|
||||
|
||||
|
||||
def test_invalid_provider(elecprice_provider, monkeypatch):
|
||||
"""Test requesting an unsupported elecprice_provider."""
|
||||
monkeypatch.setenv("elecprice_provider", "<invalid>")
|
||||
elecprice_provider.config.update()
|
||||
assert elecprice_provider.enabled() == False
|
||||
def test_invalid_provider(provider, monkeypatch):
|
||||
"""Test requesting an unsupported provider."""
|
||||
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
|
||||
provider.config.reset_settings()
|
||||
assert not provider.enabled()
|
||||
|
||||
|
||||
# ------------------------------------------------
|
||||
@@ -67,16 +68,16 @@ def test_invalid_provider(elecprice_provider, monkeypatch):
|
||||
|
||||
|
||||
@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."""
|
||||
invalid_data = '{"invalid": "data"}'
|
||||
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])
|
||||
|
||||
|
||||
@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."""
|
||||
# Mock response object
|
||||
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
|
||||
|
||||
# Preset, as this is usually done by update()
|
||||
elecprice_provider.config.update()
|
||||
provider.config.update()
|
||||
|
||||
# Test function
|
||||
akkudoktor_data = elecprice_provider._request_forecast()
|
||||
akkudoktor_data = provider._request_forecast()
|
||||
|
||||
assert isinstance(akkudoktor_data, AkkudoktorElecPrice)
|
||||
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")
|
||||
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."""
|
||||
# Mock response object
|
||||
mock_response = Mock()
|
||||
@@ -116,28 +117,28 @@ def test_update_data(mock_get, elecprice_provider, sample_akkudoktor_1_json, cac
|
||||
# Call the method
|
||||
ems_eos = get_ems()
|
||||
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
|
||||
mock_get.assert_called_once()
|
||||
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
|
||||
|
||||
# Assert we get prediction_hours prioce values by resampling
|
||||
np_price_array = elecprice_provider.key_to_array(
|
||||
# Assert we get hours prioce values by resampling
|
||||
np_price_array = provider.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=elecprice_provider.start_datetime,
|
||||
end_datetime=elecprice_provider.end_datetime,
|
||||
start_datetime=provider.start_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:
|
||||
# f_out.write(elecprice_provider.to_json())
|
||||
# f_out.write(provider.to_json())
|
||||
|
||||
|
||||
@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."""
|
||||
incomplete_data: dict = {"meta": {}, "values": []}
|
||||
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_get.return_value = mock_response
|
||||
with pytest.raises(ValueError):
|
||||
elecprice_provider._update_data(force_update=True)
|
||||
provider._update_data(force_update=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -154,7 +155,7 @@ def test_update_data_with_incomplete_forecast(mock_get, elecprice_provider):
|
||||
)
|
||||
@patch("requests.get")
|
||||
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."""
|
||||
mock_response = Mock()
|
||||
@@ -166,31 +167,31 @@ def test_request_forecast_status_codes(
|
||||
mock_get.return_value = mock_response
|
||||
if exception:
|
||||
with pytest.raises(exception):
|
||||
elecprice_provider._request_forecast()
|
||||
provider._request_forecast()
|
||||
else:
|
||||
elecprice_provider._request_forecast()
|
||||
provider._request_forecast()
|
||||
|
||||
|
||||
@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."""
|
||||
mock_cache_instance = mock_cache.return_value
|
||||
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.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."""
|
||||
elecprice_provider.update_data(force_update=True)
|
||||
array = elecprice_provider.key_to_array(
|
||||
provider.update_data(force_update=True)
|
||||
array = provider.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=elecprice_provider.start_datetime,
|
||||
end_datetime=elecprice_provider.end_datetime,
|
||||
start_datetime=provider.start_datetime,
|
||||
end_datetime=provider.end_datetime,
|
||||
)
|
||||
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")
|
||||
def test_akkudoktor_development_forecast_data(elecprice_provider):
|
||||
def test_akkudoktor_development_forecast_data(provider):
|
||||
"""Fetch data from real Akkudoktor server."""
|
||||
# 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:
|
||||
json.dump(akkudoktor_data, f_out, indent=4)
|
||||
|
@@ -13,12 +13,16 @@ FILE_TESTDATA_ELECPRICEIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.jso
|
||||
|
||||
|
||||
@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."""
|
||||
settings = {
|
||||
"elecprice_provider": "ElecPriceImport",
|
||||
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
|
||||
"elecpriceimport_json": json.dumps(sample_import_1_json),
|
||||
"elecprice": {
|
||||
"provider": "ElecPriceImport",
|
||||
"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)
|
||||
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."""
|
||||
another_instance = ElecPriceImport()
|
||||
assert elecprice_provider is another_instance
|
||||
assert provider is another_instance
|
||||
|
||||
|
||||
def test_invalid_provider(elecprice_provider, config_eos):
|
||||
"""Test requesting an unsupported elecprice_provider."""
|
||||
def test_invalid_provider(provider, config_eos):
|
||||
"""Test requesting an unsupported provider."""
|
||||
settings = {
|
||||
"elecprice_provider": "<invalid>",
|
||||
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
|
||||
"elecprice": {
|
||||
"provider": "<invalid>",
|
||||
"provider_settings": {
|
||||
"import_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
|
||||
},
|
||||
}
|
||||
}
|
||||
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)
|
||||
],
|
||||
)
|
||||
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."""
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
||||
if from_file:
|
||||
config_eos.elecpriceimport_json = None
|
||||
assert config_eos.elecpriceimport_json is None
|
||||
config_eos.elecprice.provider_settings.import_json = None
|
||||
assert config_eos.elecprice.provider_settings.import_json is None
|
||||
else:
|
||||
config_eos.elecpriceimport_file_path = None
|
||||
assert config_eos.elecpriceimport_file_path is None
|
||||
elecprice_provider.clear()
|
||||
config_eos.elecprice.provider_settings.import_file_path = None
|
||||
assert config_eos.elecprice.provider_settings.import_file_path is None
|
||||
provider.clear()
|
||||
|
||||
# Call the method
|
||||
elecprice_provider.update_data()
|
||||
provider.update_data()
|
||||
|
||||
# Assert: Verify the result is as expected
|
||||
assert elecprice_provider.start_datetime is not None
|
||||
assert elecprice_provider.total_hours is not None
|
||||
assert compare_datetimes(elecprice_provider.start_datetime, ems_eos.start_datetime).equal
|
||||
assert provider.start_datetime is not None
|
||||
assert provider.total_hours is not None
|
||||
assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
|
||||
values = sample_import_1_json["elecprice_marketprice_wh"]
|
||||
value_datetime_mapping = elecprice_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):
|
||||
assert i < len(elecprice_provider.records)
|
||||
assert i < len(provider.records)
|
||||
expected_datetime, expected_value_index = mapping
|
||||
expected_value = values[expected_value_index]
|
||||
result_datetime = elecprice_provider.records[i].date_time
|
||||
result_value = elecprice_provider.records[i]["elecprice_marketprice_wh"]
|
||||
result_datetime = provider.records[i].date_time
|
||||
result_value = provider.records[i]["elecprice_marketprice_wh"]
|
||||
|
||||
# print(f"{i}: Expected: {expected_datetime}:{expected_value}")
|
||||
# print(f"{i}: Result: {result_datetime}:{result_value}")
|
||||
|
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -6,22 +6,31 @@ from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_battery():
|
||||
def mock_battery() -> Mock:
|
||||
mock_battery = Mock()
|
||||
mock_battery.charge_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
|
||||
|
||||
|
||||
@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.calculate_self_consumption.return_value = 1.0
|
||||
return Inverter(
|
||||
mock_self_consumption_predictor,
|
||||
InverterParameters(max_power_wh=500.0),
|
||||
battery=mock_battery,
|
||||
)
|
||||
with patch(
|
||||
"akkudoktoreos.devices.inverter.get_eos_load_interpolator",
|
||||
return_value=mock_self_consumption_predictor,
|
||||
):
|
||||
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):
|
||||
|
@@ -14,12 +14,16 @@ from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_provider(config_eos):
|
||||
def provider(config_eos):
|
||||
"""Fixture to initialise the LoadAkkudoktor instance."""
|
||||
settings = {
|
||||
"load_provider": "LoadAkkudoktor",
|
||||
"load_name": "Akkudoktor Profile",
|
||||
"loadakkudoktor_year_energy": "1000",
|
||||
"load": {
|
||||
"provider": "LoadAkkudoktor",
|
||||
"provider_settings": {
|
||||
"load_name": "Akkudoktor Profile",
|
||||
"loadakkudoktor_year_energy": "1000",
|
||||
},
|
||||
}
|
||||
}
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
return LoadAkkudoktor()
|
||||
@@ -37,8 +41,8 @@ def measurement_eos():
|
||||
measurement.records.append(
|
||||
MeasurementDataRecord(
|
||||
date_time=dt,
|
||||
measurement_load0_mr=load0_mr,
|
||||
measurement_load1_mr=load1_mr,
|
||||
load0_mr=load0_mr,
|
||||
load1_mr=load1_mr,
|
||||
)
|
||||
)
|
||||
dt += interval
|
||||
@@ -72,13 +76,13 @@ def test_loadakkudoktor_settings_validator():
|
||||
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."""
|
||||
assert load_provider.provider_id() == "LoadAkkudoktor"
|
||||
assert provider.provider_id() == "LoadAkkudoktor"
|
||||
|
||||
|
||||
@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."""
|
||||
# Mock numpy load to return data similar to what would be in the file
|
||||
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
|
||||
data_year_energy = load_provider.load_data()
|
||||
data_year_energy = provider.load_data()
|
||||
assert data_year_energy is not None
|
||||
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."""
|
||||
data_year_energy = load_provider.load_data()
|
||||
data_year_energy = provider.load_data()
|
||||
assert data_year_energy is not None
|
||||
|
||||
|
||||
@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."""
|
||||
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))
|
||||
|
||||
# Assure there are no prediction records
|
||||
load_provider.clear()
|
||||
assert len(load_provider) == 0
|
||||
provider.clear()
|
||||
assert len(provider) == 0
|
||||
|
||||
# Execute the method
|
||||
load_provider._update_data()
|
||||
provider._update_data()
|
||||
|
||||
# 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."""
|
||||
data_year_energy = np.random.rand(365, 2, 24)
|
||||
|
||||
# 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 weekend_adjust.shape == (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,)
|
||||
expected = np.array(
|
||||
@@ -193,7 +197,7 @@ def test_calculate_adjustment(load_provider, measurement_eos):
|
||||
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."""
|
||||
with patch(
|
||||
"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))
|
||||
|
||||
# Test execution
|
||||
load_provider._update_data()
|
||||
provider._update_data()
|
||||
assert mock_adjust.called
|
||||
|
@@ -3,7 +3,11 @@ import pytest
|
||||
from pendulum import datetime, duration
|
||||
|
||||
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
|
||||
@@ -13,33 +17,33 @@ def measurement_eos():
|
||||
measurement.records = [
|
||||
MeasurementDataRecord(
|
||||
date_time=datetime(2023, 1, 1, hour=0),
|
||||
measurement_load0_mr=100,
|
||||
measurement_load1_mr=200,
|
||||
load0_mr=100,
|
||||
load1_mr=200,
|
||||
),
|
||||
MeasurementDataRecord(
|
||||
date_time=datetime(2023, 1, 1, hour=1),
|
||||
measurement_load0_mr=150,
|
||||
measurement_load1_mr=250,
|
||||
load0_mr=150,
|
||||
load1_mr=250,
|
||||
),
|
||||
MeasurementDataRecord(
|
||||
date_time=datetime(2023, 1, 1, hour=2),
|
||||
measurement_load0_mr=200,
|
||||
measurement_load1_mr=300,
|
||||
load0_mr=200,
|
||||
load1_mr=300,
|
||||
),
|
||||
MeasurementDataRecord(
|
||||
date_time=datetime(2023, 1, 1, hour=3),
|
||||
measurement_load0_mr=250,
|
||||
measurement_load1_mr=350,
|
||||
load0_mr=250,
|
||||
load1_mr=350,
|
||||
),
|
||||
MeasurementDataRecord(
|
||||
date_time=datetime(2023, 1, 1, hour=4),
|
||||
measurement_load0_mr=300,
|
||||
measurement_load1_mr=400,
|
||||
load0_mr=300,
|
||||
load1_mr=400,
|
||||
),
|
||||
MeasurementDataRecord(
|
||||
date_time=datetime(2023, 1, 1, hour=5),
|
||||
measurement_load0_mr=350,
|
||||
measurement_load1_mr=450,
|
||||
load0_mr=350,
|
||||
load1_mr=450,
|
||||
),
|
||||
]
|
||||
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):
|
||||
"""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)
|
||||
end_datetime = datetime(2023, 1, 1, 5)
|
||||
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):
|
||||
"""Test _energy_from_meter_readings with no data (empty array)."""
|
||||
key = "measurement_load0_mr"
|
||||
key = "load0_mr"
|
||||
start_datetime = datetime(2023, 1, 1, 0)
|
||||
end_datetime = datetime(2023, 1, 1, 5)
|
||||
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):
|
||||
"""Test _energy_from_meter_readings with misaligned array size."""
|
||||
key = "measurement_load1_mr"
|
||||
key = "load1_mr"
|
||||
start_datetime = measurement_eos.min_datetime
|
||||
end_datetime = measurement_eos.max_datetime
|
||||
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):
|
||||
"""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)
|
||||
end_datetime = datetime(2023, 1, 1, 5)
|
||||
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):
|
||||
"""Test _energy_from_meter_readings with a negative interval."""
|
||||
key = "measurement_load3_mr"
|
||||
key = "load3_mr"
|
||||
start_datetime = datetime(2023, 1, 1, 0)
|
||||
end_datetime = datetime(2023, 1, 1, 5)
|
||||
interval = duration(hours=-1)
|
||||
@@ -186,21 +190,25 @@ def test_load_total_no_data(measurement_eos):
|
||||
def test_name_to_key(measurement_eos):
|
||||
"""Test name_to_key functionality."""
|
||||
settings = SettingsEOS(
|
||||
measurement_load0_name="Household",
|
||||
measurement_load1_name="Heat Pump",
|
||||
measurement=MeasurementCommonSettings(
|
||||
load0_name="Household",
|
||||
load1_name="Heat Pump",
|
||||
)
|
||||
)
|
||||
measurement_eos.config.merge_settings(settings)
|
||||
|
||||
assert measurement_eos.name_to_key("Household", "measurement_load") == "measurement_load0_mr"
|
||||
assert measurement_eos.name_to_key("Heat Pump", "measurement_load") == "measurement_load1_mr"
|
||||
assert measurement_eos.name_to_key("Unknown", "measurement_load") is None
|
||||
assert measurement_eos.name_to_key("Household", "load") == "load0_mr"
|
||||
assert measurement_eos.name_to_key("Heat Pump", "load") == "load1_mr"
|
||||
assert measurement_eos.name_to_key("Unknown", "load") is None
|
||||
|
||||
|
||||
def test_name_to_key_invalid_topic(measurement_eos):
|
||||
"""Test name_to_key with an invalid topic."""
|
||||
settings = SettingsEOS(
|
||||
measurement_load0_name="Household",
|
||||
measurement_load1_name="Heat Pump",
|
||||
MeasurementCommonSettings(
|
||||
load0_name="Household",
|
||||
load1_name="Heat Pump",
|
||||
)
|
||||
)
|
||||
measurement_eos.config.merge_settings(settings)
|
||||
|
||||
|
@@ -17,25 +17,6 @@ from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||
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
|
||||
def prediction():
|
||||
"""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(
|
||||
"field_name, invalid_value, expected_error",
|
||||
[
|
||||
("prediction_hours", -1, "Input should be greater than or equal to 0"),
|
||||
("prediction_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"),
|
||||
("hours", -1, "Input should be greater than or equal to 0"),
|
||||
("historic_hours", -5, "Input should be greater than or equal to 0"),
|
||||
],
|
||||
)
|
||||
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."""
|
||||
valid_data = {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.0060,
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
}
|
||||
assert PredictionCommonSettings(**valid_data) is not None
|
||||
valid_data[field_name] = invalid_value
|
||||
|
||||
with pytest.raises(ValidationError, match=expected_error):
|
||||
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):
|
||||
"""Test that Prediction is initialized with the correct providers in sequence."""
|
||||
assert isinstance(prediction, Prediction)
|
||||
|
@@ -88,31 +88,31 @@ class TestPredictionBase:
|
||||
@pytest.fixture
|
||||
def base(self, monkeypatch):
|
||||
# Provide default values for configuration
|
||||
monkeypatch.setenv("latitude", "50.0")
|
||||
monkeypatch.setenv("longitude", "10.0")
|
||||
monkeypatch.setenv("EOS_PREDICTION__HOURS", "10")
|
||||
derived = DerivedBase()
|
||||
derived.config.update()
|
||||
derived.config.reset_settings()
|
||||
assert derived.config.prediction.hours == 10
|
||||
return derived
|
||||
|
||||
def test_config_value_from_env_variable(self, base, monkeypatch):
|
||||
# From Prediction Config
|
||||
monkeypatch.setenv("latitude", "2.5")
|
||||
base.config.update()
|
||||
assert base.config.latitude == 2.5
|
||||
monkeypatch.setenv("EOS_PREDICTION__HOURS", "2")
|
||||
base.config.reset_settings()
|
||||
assert base.config.prediction.hours == 2
|
||||
|
||||
def test_config_value_from_field_default(self, base, monkeypatch):
|
||||
assert base.config.model_fields["prediction_hours"].default == 48
|
||||
assert base.config.prediction_hours == 48
|
||||
monkeypatch.setenv("prediction_hours", "128")
|
||||
base.config.update()
|
||||
assert base.config.prediction_hours == 128
|
||||
monkeypatch.delenv("prediction_hours")
|
||||
base.config.update()
|
||||
assert base.config.prediction_hours == 48
|
||||
assert base.config.prediction.model_fields["historic_hours"].default == 48
|
||||
assert base.config.prediction.historic_hours == 48
|
||||
monkeypatch.setenv("EOS_PREDICTION__HISTORIC_HOURS", "128")
|
||||
base.config.reset_settings()
|
||||
assert base.config.prediction.historic_hours == 128
|
||||
monkeypatch.delenv("EOS_PREDICTION__HISTORIC_HOURS")
|
||||
base.config.reset_settings()
|
||||
assert base.config.prediction.historic_hours == 48
|
||||
|
||||
def test_get_config_value_key_error(self, base):
|
||||
with pytest.raises(AttributeError):
|
||||
base.config.non_existent_key
|
||||
base.config.prediction.non_existent_key
|
||||
|
||||
|
||||
# TestPredictionRecord fully covered by TestDataRecord
|
||||
@@ -159,14 +159,14 @@ class TestPredictionProvider:
|
||||
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(sample_start_datetime)
|
||||
provider.config.prediction_hours = 24 # 24 hours into the future
|
||||
provider.config.prediction_historic_hours = 48 # 48 hours into the past
|
||||
provider.config.prediction.hours = 24 # 24 hours into the future
|
||||
provider.config.prediction.historic_hours = 48 # 48 hours into the past
|
||||
|
||||
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(
|
||||
provider.config.prediction_historic_hours * 3600
|
||||
provider.config.prediction.historic_hours * 3600
|
||||
)
|
||||
|
||||
assert (
|
||||
@@ -183,31 +183,26 @@ class TestPredictionProvider:
|
||||
# EOS config supersedes
|
||||
ems_eos = get_ems()
|
||||
# The following values are currently not set in EOS config, we can override
|
||||
monkeypatch.setenv("prediction_historic_hours", "2")
|
||||
assert os.getenv("prediction_historic_hours") == "2"
|
||||
monkeypatch.setenv("latitude", "37.7749")
|
||||
assert os.getenv("latitude") == "37.7749"
|
||||
monkeypatch.setenv("longitude", "-122.4194")
|
||||
assert os.getenv("longitude") == "-122.4194"
|
||||
monkeypatch.setenv("EOS_PREDICTION__HISTORIC_HOURS", "2")
|
||||
assert os.getenv("EOS_PREDICTION__HISTORIC_HOURS") == "2"
|
||||
provider.config.reset_settings()
|
||||
|
||||
ems_eos.set_start_datetime(sample_start_datetime)
|
||||
provider.update_data()
|
||||
|
||||
assert provider.config.prediction_hours == config_eos.prediction_hours
|
||||
assert provider.config.prediction_historic_hours == 2
|
||||
assert provider.config.latitude == 37.7749
|
||||
assert provider.config.longitude == -122.4194
|
||||
assert provider.config.prediction.hours == config_eos.prediction.hours
|
||||
assert provider.config.prediction.historic_hours == 2
|
||||
assert provider.start_datetime == sample_start_datetime
|
||||
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")
|
||||
|
||||
def test_update_method_force_enable(self, provider, monkeypatch):
|
||||
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
|
||||
# Preset values that are needed by update
|
||||
monkeypatch.setenv("latitude", "37.7749")
|
||||
monkeypatch.setenv("longitude", "-122.4194")
|
||||
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "37.7749")
|
||||
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "-122.4194")
|
||||
|
||||
# Override enabled to return False for this test
|
||||
DerivedPredictionProvider.provider_enabled = False
|
||||
@@ -288,7 +283,9 @@ class TestPredictionContainer:
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
||||
settings = {
|
||||
"prediction_hours": hours,
|
||||
"prediction": {
|
||||
"hours": hours,
|
||||
}
|
||||
}
|
||||
container.config.merge_settings_from_dict(settings)
|
||||
expected = to_datetime(end, in_timezone="Europe/Berlin")
|
||||
@@ -316,14 +313,16 @@ class TestPredictionContainer:
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
||||
settings = {
|
||||
"prediction_historic_hours": historic_hours,
|
||||
"prediction": {
|
||||
"historic_hours": historic_hours,
|
||||
}
|
||||
}
|
||||
container.config.merge_settings_from_dict(settings)
|
||||
expected = to_datetime(expected_keep, in_timezone="Europe/Berlin")
|
||||
assert compare_datetimes(container.keep_datetime, expected).equal
|
||||
|
||||
@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-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)
|
||||
],
|
||||
)
|
||||
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."""
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
||||
settings = {
|
||||
"prediction_hours": prediction_hours,
|
||||
"prediction": {
|
||||
"hours": hours,
|
||||
}
|
||||
}
|
||||
container.config.merge_settings_from_dict(settings)
|
||||
assert container.total_hours == expected_hours
|
||||
@@ -355,7 +356,9 @@ class TestPredictionContainer:
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
|
||||
settings = {
|
||||
"prediction_historic_hours": historic_hours,
|
||||
"prediction": {
|
||||
"historic_hours": historic_hours,
|
||||
}
|
||||
}
|
||||
container.config.merge_settings_from_dict(settings)
|
||||
assert container.keep_hours == expected_hours
|
||||
|
@@ -1,82 +1,75 @@
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecast import (
|
||||
PVForecastCommonSettings,
|
||||
PVForecastPlaneSetting,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
"""Fixture that creates an empty PVForecastSettings."""
|
||||
settings = PVForecastCommonSettings()
|
||||
|
||||
# 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
|
||||
|
||||
assert settings.planes is None
|
||||
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):
|
||||
"""Test computation of peak power for active planes."""
|
||||
settings.pvforecast1_surface_tilt = 10.0
|
||||
settings.pvforecast1_surface_azimuth = 10.0
|
||||
settings.pvforecast1_peakpower = 5.0
|
||||
settings.pvforecast2_surface_tilt = 20.0
|
||||
settings.pvforecast2_surface_azimuth = 20.0
|
||||
settings.pvforecast2_peakpower = 3.5
|
||||
settings.pvforecast3_surface_tilt = 30.0
|
||||
settings.pvforecast3_surface_azimuth = 30.0
|
||||
settings.pvforecast3_modules_per_string = 20 # Should use default 5000W
|
||||
settings.planes = [
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=10.0,
|
||||
surface_azimuth=10.0,
|
||||
peakpower=5.0,
|
||||
),
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=20.0,
|
||||
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]
|
||||
assert settings.pvforecast_planes_peakpower == expected_peakpower
|
||||
assert settings.planes_peakpower == expected_peakpower
|
||||
|
||||
|
||||
def test_planes_azimuth_computation(settings):
|
||||
"""Test computation of azimuth values for active planes."""
|
||||
settings.pvforecast1_surface_tilt = 10.0
|
||||
settings.pvforecast1_surface_azimuth = 10.0
|
||||
settings.pvforecast2_surface_tilt = 20.0
|
||||
settings.pvforecast2_surface_azimuth = 20.0
|
||||
settings.planes = [
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=10.0,
|
||||
surface_azimuth=10.0,
|
||||
),
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=20.0,
|
||||
surface_azimuth=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):
|
||||
"""Test computation of tilt values for active planes."""
|
||||
settings.pvforecast1_surface_tilt = 10.0
|
||||
settings.pvforecast1_surface_azimuth = 10.0
|
||||
settings.pvforecast2_surface_tilt = 20.0
|
||||
settings.pvforecast2_surface_azimuth = 20.0
|
||||
settings.planes = [
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=10.0,
|
||||
surface_azimuth=10.0,
|
||||
),
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=20.0,
|
||||
surface_azimuth=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):
|
||||
@@ -84,116 +77,84 @@ def test_planes_userhorizon_computation(settings):
|
||||
horizon1 = [10.0, 20.0, 30.0]
|
||||
horizon2 = [5.0, 15.0, 25.0]
|
||||
|
||||
settings.pvforecast1_surface_tilt = 10.0
|
||||
settings.pvforecast1_surface_azimuth = 10.0
|
||||
settings.pvforecast1_userhorizon = horizon1
|
||||
settings.pvforecast2_surface_tilt = 20.0
|
||||
settings.pvforecast2_surface_azimuth = 20.0
|
||||
settings.pvforecast2_userhorizon = horizon2
|
||||
settings.planes = [
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=10.0,
|
||||
surface_azimuth=10.0,
|
||||
userhorizon=horizon1,
|
||||
),
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=20.0,
|
||||
surface_azimuth=20.0,
|
||||
userhorizon=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):
|
||||
"""Test computation of inverter power rating for active planes."""
|
||||
settings.pvforecast1_surface_tilt = 10.0
|
||||
settings.pvforecast1_surface_azimuth = 10.0
|
||||
settings.pvforecast1_inverter_paco = 6000
|
||||
settings.pvforecast2_surface_tilt = 20.0
|
||||
settings.pvforecast2_surface_azimuth = 20.0
|
||||
settings.pvforecast2_inverter_paco = 4000
|
||||
settings.planes = [
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=10.0,
|
||||
surface_azimuth=10.0,
|
||||
inverter_paco=6000,
|
||||
),
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=20.0,
|
||||
surface_azimuth=20.0,
|
||||
inverter_paco=4000,
|
||||
),
|
||||
]
|
||||
|
||||
expected_paco = [6000, 4000]
|
||||
assert settings.pvforecast_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]
|
||||
assert settings.planes_inverter_paco == expected_paco
|
||||
|
||||
|
||||
def test_mixed_plane_configuration(settings):
|
||||
"""Test mixed configuration with some planes having peak power and others having modules."""
|
||||
settings.pvforecast1_surface_tilt = 10.0
|
||||
settings.pvforecast1_surface_azimuth = 10.0
|
||||
settings.pvforecast1_peakpower = 5.0
|
||||
settings.pvforecast2_surface_tilt = 20.0
|
||||
settings.pvforecast2_surface_azimuth = 20.0
|
||||
settings.pvforecast2_modules_per_string = 20
|
||||
settings.pvforecast2_strings_per_inverter = 2
|
||||
settings.pvforecast4_surface_tilt = 40.0
|
||||
settings.pvforecast4_surface_azimuth = 40.0
|
||||
settings.pvforecast4_peakpower = 3.0
|
||||
settings.planes = [
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=10.0,
|
||||
surface_azimuth=10.0,
|
||||
peakpower=5.0,
|
||||
),
|
||||
PVForecastPlaneSetting(
|
||||
surface_tilt=20.0,
|
||||
surface_azimuth=20.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
|
||||
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):
|
||||
"""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)
|
||||
plane_settings = {}
|
||||
for i in range(1, 8): # Try to set up 7 planes, skipping plane 0
|
||||
plane_settings[f"pvforecast{i}_peakpower"] = 5.0
|
||||
plane_settings = [{"peakpower": 5.0} for _ in range(8)]
|
||||
|
||||
settings = PVForecastCommonSettings(**plane_settings)
|
||||
assert len(settings.pvforecast_planes) <= settings.pvforecast_max_planes
|
||||
with pytest.raises(ValueError):
|
||||
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."""
|
||||
settings.pvforecast1_peakpower = 5.0
|
||||
settings.pvforecast1_albedo = None
|
||||
settings.pvforecast1_module_model = None
|
||||
settings.pvforecast1_userhorizon = None
|
||||
|
||||
assert settings.pvforecast1_albedo is 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
|
||||
with pytest.raises(ValueError):
|
||||
PVForecastPlaneSetting(
|
||||
peakpower=5.0,
|
||||
albedo=None,
|
||||
module_model=None,
|
||||
userhorizon=None,
|
||||
)
|
||||
|
@@ -25,36 +25,52 @@ FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1
|
||||
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,
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": -10,
|
||||
"pvforecast0_surface_tilt": 7,
|
||||
"pvforecast0_userhorizon": [20, 27, 22, 20],
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"pvforecast1_peakpower": 4.8,
|
||||
"pvforecast1_surface_azimuth": -90,
|
||||
"pvforecast1_surface_tilt": 7,
|
||||
"pvforecast1_userhorizon": [30, 30, 30, 50],
|
||||
"pvforecast1_inverter_paco": 10000,
|
||||
"pvforecast2_peakpower": 1.4,
|
||||
"pvforecast2_surface_azimuth": -40,
|
||||
"pvforecast2_surface_tilt": 60,
|
||||
"pvforecast2_userhorizon": [60, 30, 0, 30],
|
||||
"pvforecast2_inverter_paco": 2000,
|
||||
"pvforecast3_peakpower": 1.6,
|
||||
"pvforecast3_surface_azimuth": 5,
|
||||
"pvforecast3_surface_tilt": 45,
|
||||
"pvforecast3_userhorizon": [45, 25, 30, 60],
|
||||
"pvforecast3_inverter_paco": 1400,
|
||||
"pvforecast4_peakpower": None,
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Merge settings to config
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
assert config_eos.pvforecast.provider == "PVForecastAkkudoktor"
|
||||
return config_eos
|
||||
|
||||
|
||||
@@ -141,15 +157,25 @@ sample_value = AkkudoktorForecastValue(
|
||||
windspeed_10m=10.0,
|
||||
)
|
||||
sample_config_data = {
|
||||
"prediction_hours": 48,
|
||||
"prediction_historic_hours": 24,
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"pvforecast_provider": "PVForecastAkkudoktor",
|
||||
"pvforecast0_peakpower": 5.0,
|
||||
"pvforecast0_surface_azimuth": 180,
|
||||
"pvforecast0_surface_tilt": 30,
|
||||
"pvforecast0_inverter_paco": 10000,
|
||||
"general": {
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
},
|
||||
"prediction": {
|
||||
"hours": 48,
|
||||
"historic_hours": 24,
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": 180,
|
||||
"surface_tilt": 30,
|
||||
"inverter_paco": 10000,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
@@ -13,12 +13,16 @@ FILE_TESTDATA_PVFORECASTIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.js
|
||||
|
||||
|
||||
@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."""
|
||||
settings = {
|
||||
"pvforecast_provider": "PVForecastImport",
|
||||
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
|
||||
"pvforecastimport_json": json.dumps(sample_import_1_json),
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastImport",
|
||||
"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)
|
||||
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."""
|
||||
another_instance = PVForecastImport()
|
||||
assert pvforecast_provider is another_instance
|
||||
assert provider is another_instance
|
||||
|
||||
|
||||
def test_invalid_provider(pvforecast_provider, config_eos):
|
||||
"""Test requesting an unsupported pvforecast_provider."""
|
||||
def test_invalid_provider(provider, config_eos):
|
||||
"""Test requesting an unsupported provider."""
|
||||
settings = {
|
||||
"pvforecast_provider": "<invalid>",
|
||||
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
|
||||
"pvforecast": {
|
||||
"provider": "<invalid>",
|
||||
"provider_settings": {
|
||||
"import_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
|
||||
},
|
||||
}
|
||||
}
|
||||
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)
|
||||
],
|
||||
)
|
||||
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."""
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
||||
if from_file:
|
||||
config_eos.pvforecastimport_json = None
|
||||
assert config_eos.pvforecastimport_json is None
|
||||
config_eos.pvforecast.provider_settings.import_json = None
|
||||
assert config_eos.pvforecast.provider_settings.import_json is None
|
||||
else:
|
||||
config_eos.pvforecastimport_file_path = None
|
||||
assert config_eos.pvforecastimport_file_path is None
|
||||
pvforecast_provider.clear()
|
||||
config_eos.pvforecast.provider_settings.import_file_path = None
|
||||
assert config_eos.pvforecast.provider_settings.import_file_path is None
|
||||
provider.clear()
|
||||
|
||||
# Call the method
|
||||
pvforecast_provider.update_data()
|
||||
provider.update_data()
|
||||
|
||||
# Assert: Verify the result is as expected
|
||||
assert pvforecast_provider.start_datetime is not None
|
||||
assert pvforecast_provider.total_hours is not None
|
||||
assert compare_datetimes(pvforecast_provider.start_datetime, ems_eos.start_datetime).equal
|
||||
assert provider.start_datetime is not None
|
||||
assert provider.total_hours is not None
|
||||
assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
|
||||
values = sample_import_1_json["pvforecast_ac_power"]
|
||||
value_datetime_mapping = pvforecast_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):
|
||||
assert i < len(pvforecast_provider.records)
|
||||
assert i < len(provider.records)
|
||||
expected_datetime, expected_value_index = mapping
|
||||
expected_value = values[expected_value_index]
|
||||
result_datetime = pvforecast_provider.records[i].date_time
|
||||
result_value = pvforecast_provider.records[i]["pvforecast_ac_power"]
|
||||
result_datetime = provider.records[i].date_time
|
||||
result_value = provider.records[i]["pvforecast_ac_power"]
|
||||
|
||||
# print(f"{i}: Expected: {expected_datetime}:{expected_value}")
|
||||
# print(f"{i}: Result: {result_datetime}:{result_value}")
|
||||
|
@@ -6,8 +6,8 @@ import requests
|
||||
def test_server(server, config_eos):
|
||||
"""Test the server."""
|
||||
# validate correct path in server
|
||||
assert config_eos.data_folder_path is not None
|
||||
assert config_eos.data_folder_path.is_dir()
|
||||
assert config_eos.general.data_folder_path is not None
|
||||
assert config_eos.general.data_folder_path.is_dir()
|
||||
|
||||
result = requests.get(f"{server}/v1/config")
|
||||
assert result.status_code == HTTPStatus.OK
|
||||
|
@@ -13,7 +13,7 @@ reference_file = DIR_TESTDATA / "test_example_report.pdf"
|
||||
|
||||
def test_generate_pdf_example(config_eos):
|
||||
"""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
|
||||
output_file = output_dir / filename
|
||||
assert not output_file.exists()
|
||||
|
@@ -17,11 +17,11 @@ FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_b
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def weather_provider(monkeypatch):
|
||||
def provider(monkeypatch):
|
||||
"""Fixture to create a WeatherProvider instance."""
|
||||
monkeypatch.setenv("weather_provider", "BrightSky")
|
||||
monkeypatch.setenv("latitude", "50.0")
|
||||
monkeypatch.setenv("longitude", "10.0")
|
||||
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "BrightSky")
|
||||
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "50.0")
|
||||
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "10.0")
|
||||
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."""
|
||||
another_instance = WeatherBrightSky()
|
||||
assert weather_provider is another_instance
|
||||
assert provider is another_instance
|
||||
|
||||
|
||||
def test_invalid_provider(weather_provider, monkeypatch):
|
||||
"""Test requesting an unsupported weather_provider."""
|
||||
monkeypatch.setenv("weather_provider", "<invalid>")
|
||||
weather_provider.config.update()
|
||||
assert not weather_provider.enabled()
|
||||
def test_invalid_provider(provider, monkeypatch):
|
||||
"""Test requesting an unsupported provider."""
|
||||
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "<invalid>")
|
||||
provider.config.reset_settings()
|
||||
assert not provider.enabled()
|
||||
|
||||
|
||||
def test_invalid_coordinates(weather_provider, monkeypatch):
|
||||
def test_invalid_coordinates(provider, monkeypatch):
|
||||
"""Test invalid coordinates raise ValueError."""
|
||||
monkeypatch.setenv("latitude", "1000")
|
||||
monkeypatch.setenv("longitude", "1000")
|
||||
monkeypatch.setenv("EOS_GENERAL__LATITUDE", "1000")
|
||||
monkeypatch.setenv("EOS_GENERAL__LONGITUDE", "1000")
|
||||
with pytest.raises(
|
||||
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."""
|
||||
cloud_cover_data = pd.Series(
|
||||
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(
|
||||
50.0, 10.0, cloud_cover_data
|
||||
)
|
||||
ghi, dni, dhi = provider.estimate_irradiance_from_cloud_cover(50.0, 10.0, cloud_cover_data)
|
||||
|
||||
assert ghi == [0, 0, 0]
|
||||
assert dhi == [0, 0, 0]
|
||||
@@ -101,7 +99,7 @@ def test_irridiance_estimate_from_cloud_cover(weather_provider):
|
||||
|
||||
|
||||
@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."""
|
||||
# Mock response object
|
||||
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
|
||||
|
||||
# Preset, as this is usually done by update()
|
||||
weather_provider.config.update()
|
||||
provider.config.update()
|
||||
|
||||
# Test function
|
||||
brightsky_data = weather_provider._request_forecast()
|
||||
brightsky_data = provider._request_forecast()
|
||||
|
||||
assert isinstance(brightsky_data, dict)
|
||||
assert brightsky_data["weather"][0] == {
|
||||
@@ -150,7 +148,7 @@ def test_request_forecast(mock_get, weather_provider, sample_brightsky_1_json):
|
||||
|
||||
|
||||
@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."""
|
||||
# Mock response object
|
||||
mock_response = Mock()
|
||||
@@ -163,14 +161,14 @@ def test_update_data(mock_get, weather_provider, sample_brightsky_1_json, cache_
|
||||
# Call the method
|
||||
ems_eos = get_ems()
|
||||
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
|
||||
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:
|
||||
# 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")
|
||||
def test_brightsky_development_forecast_data(weather_provider):
|
||||
def test_brightsky_development_forecast_data(provider):
|
||||
"""Fetch data from real BrightSky server."""
|
||||
# Preset, as this is usually done by update_data()
|
||||
weather_provider.start_datetime = to_datetime("2024-10-26 00:00:00")
|
||||
weather_provider.latitude = 50.0
|
||||
weather_provider.longitude = 10.0
|
||||
provider.start_datetime = to_datetime("2024-10-26 00:00:00")
|
||||
provider.latitude = 50.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:
|
||||
json.dump(brightsky_data, f_out, indent=4)
|
||||
|
@@ -21,12 +21,16 @@ FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA = DIR_TESTDATA.joinpath("weatherforecas
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def weather_provider(config_eos):
|
||||
def provider(config_eos):
|
||||
"""Fixture to create a WeatherProvider instance."""
|
||||
settings = {
|
||||
"weather_provider": "ClearOutside",
|
||||
"latitude": 50.0,
|
||||
"longitude": 10.0,
|
||||
"weather": {
|
||||
"provider": "ClearOutside",
|
||||
},
|
||||
"general": {
|
||||
"latitude": 50.0,
|
||||
"longitude": 10.0,
|
||||
},
|
||||
}
|
||||
config_eos.merge_settings_from_dict(settings)
|
||||
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."""
|
||||
another_instance = WeatherClearOutside()
|
||||
assert weather_provider is another_instance
|
||||
assert provider is another_instance
|
||||
|
||||
|
||||
def test_invalid_provider(weather_provider, config_eos):
|
||||
"""Test requesting an unsupported weather_provider."""
|
||||
def test_invalid_provider(provider, config_eos):
|
||||
"""Test requesting an unsupported provider."""
|
||||
settings = {
|
||||
"weather_provider": "<invalid>",
|
||||
"weather": {
|
||||
"provider": "<invalid>",
|
||||
}
|
||||
}
|
||||
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."""
|
||||
settings = {
|
||||
"weather_provider": "ClearOutside",
|
||||
"latitude": 1000.0,
|
||||
"longitude": 1000.0,
|
||||
"weather": {
|
||||
"provider": "ClearOutside",
|
||||
},
|
||||
"general": {
|
||||
"latitude": 1000.0,
|
||||
"longitude": 1000.0,
|
||||
},
|
||||
}
|
||||
with pytest.raises(
|
||||
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."""
|
||||
cloud_cover_data = pd.Series(
|
||||
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(
|
||||
50.0, 10.0, cloud_cover_data
|
||||
)
|
||||
ghi, dni, dhi = provider.estimate_irradiance_from_cloud_cover(50.0, 10.0, cloud_cover_data)
|
||||
|
||||
assert ghi == [0, 0, 0]
|
||||
assert dhi == [0, 0, 0]
|
||||
@@ -114,7 +122,7 @@ def test_irridiance_estimate_from_cloud_cover(weather_provider):
|
||||
|
||||
|
||||
@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."""
|
||||
# Mock response object
|
||||
mock_response = Mock()
|
||||
@@ -126,14 +134,14 @@ def test_request_forecast(mock_get, weather_provider, sample_clearout_1_html, co
|
||||
config_eos.update()
|
||||
|
||||
# Test function
|
||||
response = weather_provider._request_forecast()
|
||||
response = provider._request_forecast()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == sample_clearout_1_html
|
||||
|
||||
|
||||
@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 = Mock()
|
||||
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
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(expected_start)
|
||||
weather_provider.update_data()
|
||||
provider.update_data()
|
||||
|
||||
# Check for correct prediction time window
|
||||
assert weather_provider.config.prediction_hours == 48
|
||||
assert weather_provider.config.prediction_historic_hours == 48
|
||||
assert compare_datetimes(weather_provider.start_datetime, expected_start).equal
|
||||
assert compare_datetimes(weather_provider.end_datetime, expected_end).equal
|
||||
assert compare_datetimes(weather_provider.keep_datetime, expected_keep).equal
|
||||
assert provider.config.prediction.hours == 48
|
||||
assert provider.config.prediction.historic_hours == 48
|
||||
assert compare_datetimes(provider.start_datetime, expected_start).equal
|
||||
assert compare_datetimes(provider.end_datetime, expected_end).equal
|
||||
assert compare_datetimes(provider.keep_datetime, expected_keep).equal
|
||||
|
||||
# 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
|
||||
# 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")
|
||||
@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.
|
||||
|
||||
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)
|
||||
|
||||
weather_provider.update_data()
|
||||
provider.update_data()
|
||||
mock_get.assert_called_once()
|
||||
forecast_data_first = weather_provider.to_json()
|
||||
forecast_data_first = provider.to_json()
|
||||
|
||||
weather_provider.update_data()
|
||||
forecast_data_second = weather_provider.to_json()
|
||||
provider.update_data()
|
||||
forecast_data_second = provider.to_json()
|
||||
# Verify that cache returns the same object without calling the method again
|
||||
assert forecast_data_first == forecast_data_second
|
||||
# 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")
|
||||
@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 = Mock()
|
||||
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
|
||||
|
||||
# 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:
|
||||
f_out.write(weather_provider.to_json())
|
||||
f_out.write(provider.to_json())
|
||||
|
||||
|
||||
@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."""
|
||||
soup = BeautifulSoup(sample_clearout_1_html, "html.parser")
|
||||
|
||||
|
@@ -13,12 +13,16 @@ FILE_TESTDATA_WEATHERIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json"
|
||||
|
||||
|
||||
@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."""
|
||||
settings = {
|
||||
"weather_provider": "WeatherImport",
|
||||
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
|
||||
"weatherimport_json": json.dumps(sample_import_1_json),
|
||||
"weather": {
|
||||
"provider": "WeatherImport",
|
||||
"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)
|
||||
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."""
|
||||
another_instance = WeatherImport()
|
||||
assert weather_provider is another_instance
|
||||
assert provider is another_instance
|
||||
|
||||
|
||||
def test_invalid_provider(weather_provider, config_eos, monkeypatch):
|
||||
"""Test requesting an unsupported weather_provider."""
|
||||
def test_invalid_provider(provider, config_eos, monkeypatch):
|
||||
"""Test requesting an unsupported provider."""
|
||||
settings = {
|
||||
"weather_provider": "<invalid>",
|
||||
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
|
||||
"weather": {
|
||||
"provider": "<invalid>",
|
||||
"provider_settings": {
|
||||
"import_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
|
||||
},
|
||||
}
|
||||
}
|
||||
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)
|
||||
],
|
||||
)
|
||||
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."""
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
|
||||
if from_file:
|
||||
config_eos.weatherimport_json = None
|
||||
assert config_eos.weatherimport_json is None
|
||||
config_eos.weather.provider_settings.import_json = None
|
||||
assert config_eos.weather.provider_settings.import_json is None
|
||||
else:
|
||||
config_eos.weatherimport_file_path = None
|
||||
assert config_eos.weatherimport_file_path is None
|
||||
weather_provider.clear()
|
||||
config_eos.weather.provider_settings.import_file_path = None
|
||||
assert config_eos.weather.provider_settings.import_file_path is None
|
||||
provider.clear()
|
||||
|
||||
# Call the method
|
||||
weather_provider.update_data()
|
||||
provider.update_data()
|
||||
|
||||
# Assert: Verify the result is as expected
|
||||
assert weather_provider.start_datetime is not None
|
||||
assert weather_provider.total_hours is not None
|
||||
assert compare_datetimes(weather_provider.start_datetime, ems_eos.start_datetime).equal
|
||||
assert provider.start_datetime is not None
|
||||
assert provider.total_hours is not None
|
||||
assert compare_datetimes(provider.start_datetime, ems_eos.start_datetime).equal
|
||||
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):
|
||||
assert i < len(weather_provider.records)
|
||||
assert i < len(provider.records)
|
||||
expected_datetime, expected_value_index = mapping
|
||||
expected_value = values[expected_value_index]
|
||||
result_datetime = weather_provider.records[i].date_time
|
||||
result_value = weather_provider.records[i]["weather_temp_air"]
|
||||
result_datetime = provider.records[i].date_time
|
||||
result_value = provider.records[i]["weather_temp_air"]
|
||||
|
||||
# print(f"{i}: Expected: {expected_datetime}:{expected_value}")
|
||||
# print(f"{i}: Result: {result_datetime}:{result_value}")
|
||||
|
110
tests/testdata/EOS.config.json
vendored
110
tests/testdata/EOS.config.json
vendored
@@ -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
|
||||
}
|
6
tests/testdata/optimize_input_1.json
vendored
6
tests/testdata/optimize_input_1.json
vendored
@@ -26,15 +26,19 @@
|
||||
]
|
||||
},
|
||||
"pv_akku": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 26400,
|
||||
"max_charge_power_w": 5000,
|
||||
"initial_soc_percentage": 80,
|
||||
"min_soc_percentage": 15
|
||||
},
|
||||
"inverter": {
|
||||
"max_power_wh": 10000
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 10000,
|
||||
"battery_id": "battery1"
|
||||
},
|
||||
"eauto": {
|
||||
"device_id": "ev1",
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.95,
|
||||
"discharging_efficiency": 1.0,
|
||||
|
8
tests/testdata/optimize_input_2.json
vendored
8
tests/testdata/optimize_input_2.json
vendored
@@ -154,6 +154,7 @@
|
||||
]
|
||||
},
|
||||
"pv_akku": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 26400,
|
||||
"initial_soc_percentage": 80,
|
||||
"min_soc_percentage": 0
|
||||
@@ -162,13 +163,20 @@
|
||||
"max_power_wh": 10000
|
||||
},
|
||||
"eauto": {
|
||||
"device_id": "ev1",
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.95,
|
||||
"max_charge_power_w": 11040,
|
||||
"initial_soc_percentage": 5,
|
||||
"min_soc_percentage": 80
|
||||
},
|
||||
"inverter": {
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 10000,
|
||||
"battery_id": "battery1"
|
||||
},
|
||||
"dishwasher": {
|
||||
"device_id": "dishwasher1",
|
||||
"consumption_wh": 5000,
|
||||
"duration_h": 2
|
||||
},
|
||||
|
1
tests/testdata/optimize_result_1.json
vendored
1
tests/testdata/optimize_result_1.json
vendored
@@ -557,6 +557,7 @@
|
||||
]
|
||||
},
|
||||
"eauto_obj": {
|
||||
"device_id": "ev1",
|
||||
"charge_array": [
|
||||
1.0,
|
||||
1.0,
|
||||
|
1
tests/testdata/optimize_result_2.json
vendored
1
tests/testdata/optimize_result_2.json
vendored
@@ -606,6 +606,7 @@
|
||||
]
|
||||
},
|
||||
"eauto_obj": {
|
||||
"device_id": "ev1",
|
||||
"charge_array": [
|
||||
1.0,
|
||||
1.0,
|
||||
|
1
tests/testdata/optimize_result_2_full.json
vendored
1
tests/testdata/optimize_result_2_full.json
vendored
@@ -606,6 +606,7 @@
|
||||
]
|
||||
},
|
||||
"eauto_obj": {
|
||||
"device_id": "ev1",
|
||||
"charge_array": [
|
||||
1.0,
|
||||
1.0,
|
||||
|
Reference in New Issue
Block a user