Compare commits
1 Commits
translatio
...
dl_dev-arc
Author | SHA1 | Date | |
---|---|---|---|
|
87ac127817 |
2
.env
@@ -3,3 +3,5 @@ EOS_SERVER__PORT=8503
|
|||||||
EOS_SERVER__EOSDASH_PORT=8504
|
EOS_SERVER__EOSDASH_PORT=8504
|
||||||
|
|
||||||
PYTHON_VERSION=3.12.6
|
PYTHON_VERSION=3.12.6
|
||||||
|
BASE_IMAGE=python
|
||||||
|
IMAGE_SUFFIX=-slim
|
||||||
|
85
.github/workflows/docker-build.yml
vendored
@@ -38,7 +38,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
if ${{ github.event_name == 'pull_request' }}; then
|
if ${{ github.event_name == 'pull_request' }}; then
|
||||||
echo 'matrix=[
|
echo 'matrix=[
|
||||||
{"platform": "linux/arm64"}
|
{"platform": {"name": "linux/amd64"}},
|
||||||
|
{"platform": {"name": "linux/arm64"}},
|
||||||
|
{"platform": {"name": "linux/386"}},
|
||||||
]' | tr -d '[:space:]' >> $GITHUB_OUTPUT
|
]' | tr -d '[:space:]' >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo 'matrix=[]' >> $GITHUB_OUTPUT
|
echo 'matrix=[]' >> $GITHUB_OUTPUT
|
||||||
@@ -56,13 +58,69 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform:
|
platform:
|
||||||
- linux/amd64
|
- name: linux/amd64
|
||||||
- linux/arm64
|
base: python
|
||||||
|
python: 3.12 # pendulum not yet on pypi for 3.13
|
||||||
|
rustup_install: ""
|
||||||
|
apt_packages: ""
|
||||||
|
apt_build_packages: ""
|
||||||
|
pip_extra_url: ""
|
||||||
|
- name: linux/arm64
|
||||||
|
base: python
|
||||||
|
python: 3.12 # pendulum not yet on pypi for 3.13
|
||||||
|
rustup_install: ""
|
||||||
|
apt_packages: ""
|
||||||
|
apt_build_packages: ""
|
||||||
|
pip_extra_url: ""
|
||||||
|
- name: linux/arm/v6
|
||||||
|
base: python
|
||||||
|
python: 3.11 # highest version on piwheels
|
||||||
|
rustup_install: true
|
||||||
|
# numpy: libopenblas0
|
||||||
|
# h5py: libhdf5-hl-310
|
||||||
|
#apt_packages: "libopenblas0 libhdf5-hl-310"
|
||||||
|
apt_packages: "" #TODO verify
|
||||||
|
# pendulum: git (apply patch)
|
||||||
|
# matplotlib (countourpy): g++
|
||||||
|
# fastapi (MarkupSafe): gcc
|
||||||
|
# rustup installer: curl
|
||||||
|
apt_build_packages: "curl git g++"
|
||||||
|
pip_extra_url: "https://www.piwheels.org/simple" # armv6/v7 packages
|
||||||
|
- name: linux/arm/v7
|
||||||
|
base: python
|
||||||
|
python: 3.11 # highest version on piwheels
|
||||||
|
rustup_install: true
|
||||||
|
# numpy: libopenblas0
|
||||||
|
# h5py: libhdf5-hl-310
|
||||||
|
#apt_packages: "libopenblas0 libhdf5-hl-310"
|
||||||
|
apt_packages: "" #TODO verify
|
||||||
|
# pendulum: git (apply patch)
|
||||||
|
# matplotlib (countourpy): g++
|
||||||
|
# fastapi (MarkupSafe): gcc
|
||||||
|
# rustup installer: curl
|
||||||
|
apt_build_packages: "curl git g++"
|
||||||
|
pip_extra_url: "https://www.piwheels.org/simple" # armv6/v7 packages
|
||||||
|
- name: linux/386
|
||||||
|
# Get 32bit distributor fix for pendulum, not yet officially released.
|
||||||
|
# Needs Debian testing instead of python:xyz which is based on Debian stable.
|
||||||
|
base: debian
|
||||||
|
python: trixie
|
||||||
|
rustup_install: ""
|
||||||
|
# numpy: libopenblas0
|
||||||
|
# h5py: libhdf5-hl-310
|
||||||
|
apt_packages: "python3-pendulum python3-pip libopenblas0 libhdf5-hl-310"
|
||||||
|
# numpy: g++, libc-dev
|
||||||
|
# skikit: pkgconf python3-dev, libopenblas-dev
|
||||||
|
# uvloop: make
|
||||||
|
# h5py: libhdf5-dev
|
||||||
|
# many others g++/gcc
|
||||||
|
apt_build_packages: "g++ pkgconf libc-dev python3-dev make libopenblas-dev libhdf5-dev"
|
||||||
|
pip_extra_url: ""
|
||||||
exclude: ${{ fromJSON(needs.platform-excludes.outputs.excludes) }}
|
exclude: ${{ fromJSON(needs.platform-excludes.outputs.excludes) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
platform=${{ matrix.platform }}
|
platform=${{ matrix.platform.name }}
|
||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
@@ -96,7 +154,8 @@ jobs:
|
|||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
# skip for pull requests
|
# skip for pull requests
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
#TODO: uncomment again
|
||||||
|
#if: ${{ github.event_name != 'pull_request' }}
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -104,8 +163,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
# skip for pull requests
|
#if: ${{ github.event_name != 'pull_request' }}
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -114,10 +172,19 @@ jobs:
|
|||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform.name }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
annotations: ${{ steps.meta.outputs.annotations }}
|
annotations: ${{ steps.meta.outputs.annotations }}
|
||||||
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,"push=${{ github.event_name != 'pull_request' }}","annotation-index.org.opencontainers.image.description=${{ env.EOS_REPO_DESCRIPTION }}"
|
#TODO: uncomment again
|
||||||
|
#outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,"push=${{ github.event_name != 'pull_request' }}","annotation-index.org.opencontainers.image.description=${{ env.EOS_REPO_DESCRIPTION }}"
|
||||||
|
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true,"annotation-index.org.opencontainers.image.description=${{ env.EOS_REPO_DESCRIPTION }}"
|
||||||
|
build-args: |
|
||||||
|
BASE_IMAGE=${{ matrix.platform.base }}
|
||||||
|
PYTHON_VERSION=${{ matrix.platform.python }}
|
||||||
|
PIP_EXTRA_INDEX_URL=${{ matrix.platform.pip_extra_url }}
|
||||||
|
APT_PACKAGES=${{ matrix.platform.apt_packages }}
|
||||||
|
APT_BUILD_PACKAGES=${{ matrix.platform.apt_build_packages }}
|
||||||
|
RUSTUP_INSTALL=${{ matrix.platform.rustup_install }}
|
||||||
|
|
||||||
- name: Generate artifact attestation DockerHub
|
- name: Generate artifact attestation DockerHub
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
|
@@ -12,12 +12,12 @@ repos:
|
|||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
exclude: '\.rst$' # Exclude .rst files
|
exclude: '\.rst$' # Exclude .rst files
|
||||||
- repo: https://github.com/PyCQA/isort
|
- repo: https://github.com/PyCQA/isort
|
||||||
rev: 6.0.0
|
rev: 5.13.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
name: isort
|
name: isort
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.9.6
|
rev: v0.6.8
|
||||||
hooks:
|
hooks:
|
||||||
# Run the linter and fix simple issues automatically
|
# Run the linter and fix simple issues automatically
|
||||||
- id: ruff
|
- id: ruff
|
||||||
@@ -25,7 +25,7 @@ repos:
|
|||||||
# Run the formatter.
|
# Run the formatter.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: 'v1.15.0'
|
rev: 'v1.13.0'
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
|
@@ -33,7 +33,6 @@ See also [README.md](README.md).
|
|||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
pip install -e .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Install make to get access to helpful shortcuts (documentation generation, manual formatting, etc.).
|
Install make to get access to helpful shortcuts (documentation generation, manual formatting, etc.).
|
||||||
|
88
Dockerfile
@@ -1,5 +1,7 @@
|
|||||||
ARG PYTHON_VERSION=3.12.7
|
ARG PYTHON_VERSION=3.12.8
|
||||||
FROM python:${PYTHON_VERSION}-slim
|
ARG BASE_IMAGE=python
|
||||||
|
ARG IMAGE_SUFFIX=-slim
|
||||||
|
FROM ${BASE_IMAGE}:${PYTHON_VERSION}${IMAGE_SUFFIX} AS base
|
||||||
|
|
||||||
LABEL source="https://github.com/Akkudoktor-EOS/EOS"
|
LABEL source="https://github.com/Akkudoktor-EOS/EOS"
|
||||||
|
|
||||||
@@ -11,7 +13,8 @@ ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
|
|||||||
|
|
||||||
WORKDIR ${EOS_DIR}
|
WORKDIR ${EOS_DIR}
|
||||||
|
|
||||||
RUN adduser --system --group --no-create-home eos \
|
# Use useradd over adduser to support both debian:x-slim and python:x-slim base images
|
||||||
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin eos \
|
||||||
&& mkdir -p "${MPLCONFIGDIR}" \
|
&& mkdir -p "${MPLCONFIGDIR}" \
|
||||||
&& chown eos "${MPLCONFIGDIR}" \
|
&& chown eos "${MPLCONFIGDIR}" \
|
||||||
&& mkdir -p "${EOS_CACHE_DIR}" \
|
&& mkdir -p "${EOS_CACHE_DIR}" \
|
||||||
@@ -21,13 +24,85 @@ RUN adduser --system --group --no-create-home eos \
|
|||||||
&& mkdir -p "${EOS_CONFIG_DIR}" \
|
&& mkdir -p "${EOS_CONFIG_DIR}" \
|
||||||
&& chown eos "${EOS_CONFIG_DIR}"
|
&& chown eos "${EOS_CONFIG_DIR}"
|
||||||
|
|
||||||
|
ARG APT_PACKAGES
|
||||||
|
ENV APT_PACKAGES="${APT_PACKAGES}"
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/var/lib/apt/lists \
|
||||||
|
--mount=type=cache,sharing=locked,target=/var/cache/apt \
|
||||||
|
rm /etc/apt/apt.conf.d/docker-clean; \
|
||||||
|
if [ -n "${APT_PACKAGES}" ]; then \
|
||||||
|
apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ${APT_PACKAGES}; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
ARG APT_BUILD_PACKAGES
|
||||||
|
ENV APT_BUILD_PACKAGES="${APT_BUILD_PACKAGES}"
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/var/lib/apt/lists \
|
||||||
|
--mount=type=cache,sharing=locked,target=/var/cache/apt \
|
||||||
|
rm /etc/apt/apt.conf.d/docker-clean; \
|
||||||
|
if [ -n "${APT_BUILD_PACKAGES}" ]; then \
|
||||||
|
apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ${APT_BUILD_PACKAGES}; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARG RUSTUP_INSTALL
|
||||||
|
ENV RUSTUP_INSTALL="${RUSTUP_INSTALL}"
|
||||||
|
ENV RUSTUP_HOME=/opt/rust
|
||||||
|
ENV CARGO_HOME=/opt/rust
|
||||||
|
ENV PATH="$RUSTUP_HOME/bin:$PATH"
|
||||||
|
ARG PIP_EXTRA_INDEX_URL
|
||||||
|
ENV PIP_EXTRA_INDEX_URL="${PIP_EXTRA_INDEX_URL}"
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
--mount=type=tmpfs,target=/root/.cargo \
|
||||||
|
dpkgArch=$(dpkg --print-architecture) \
|
||||||
|
&& if [ -n "${RUSTUP_INSTALL}" ]; then \
|
||||||
|
case "$dpkgArch" in \
|
||||||
|
# armv6
|
||||||
|
armel) \
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --target arm-unknown-linux-gnueabi --no-modify-path \
|
||||||
|
;; \
|
||||||
|
*) \
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path \
|
||||||
|
;; \
|
||||||
|
esac \
|
||||||
|
&& rustc --version \
|
||||||
|
&& cargo --version; \
|
||||||
|
fi \
|
||||||
|
# Install 32bit fix for pendulum, can be removed after next pendulum release (> 3.0.0)
|
||||||
|
&& case "$dpkgArch" in \
|
||||||
|
# armv7/armv6
|
||||||
|
armhf|armel) \
|
||||||
|
git clone https://github.com/python-pendulum/pendulum.git \
|
||||||
|
&& git -C pendulum checkout -b 3.0.0 3.0.0 \
|
||||||
|
# Apply 32bit patch
|
||||||
|
&& git -C pendulum -c user.name=ci -c user.email=ci@github.com cherry-pick b84b97625cdea00f8ab150b8b35aa5ccaaf36948 \
|
||||||
|
&& cd pendulum \
|
||||||
|
# Use pip3 over pip to support both debian:x and python:x base images
|
||||||
|
&& pip3 install maturin \
|
||||||
|
&& maturin build --release --out dist \
|
||||||
|
&& pip3 install dist/*.whl --break-system-packages \
|
||||||
|
&& cd - \
|
||||||
|
;; \
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Use tmpfs for cargo due to qemu (multiarch) limitations
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
pip install -r requirements.txt
|
--mount=type=tmpfs,target=/root/.cargo \
|
||||||
|
# Use pip3 over pip to support both debian:x and python:x base images
|
||||||
|
pip3 install -r requirements.txt --break-system-packages
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
# Copy all python dependencies previously installed or built to the final stage.
|
||||||
|
COPY --from=build /usr/local/ /usr/local/
|
||||||
|
COPY --from=build /opt/eos/requirements.txt .
|
||||||
|
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN mkdir -p src && pip install -e .
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
# Use pip3 over pip to support both debian:x and python:x base images
|
||||||
|
mkdir -p src && pip3 install -e . --break-system-packages
|
||||||
|
|
||||||
COPY src src
|
COPY src src
|
||||||
|
|
||||||
@@ -37,6 +112,7 @@ ENTRYPOINT []
|
|||||||
EXPOSE 8503
|
EXPOSE 8503
|
||||||
EXPOSE 8504
|
EXPOSE 8504
|
||||||
|
|
||||||
CMD ["python", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"]
|
# Use python3 over python to support both debian:x and python:x base images
|
||||||
|
CMD ["python3", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"]
|
VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"]
|
||||||
|
18
Makefile
@@ -19,10 +19,8 @@ help:
|
|||||||
@echo " read-docs - Read HTML documentation in your browser."
|
@echo " read-docs - Read HTML documentation in your browser."
|
||||||
@echo " gen-docs - Generate openapi.json and docs/_generated/*.""
|
@echo " gen-docs - Generate openapi.json and docs/_generated/*.""
|
||||||
@echo " clean-docs - Remove generated documentation.""
|
@echo " clean-docs - Remove generated documentation.""
|
||||||
@echo " run - Run EOS production server in virtual environment."
|
@echo " run - Run EOS production server in the virtual environment."
|
||||||
@echo " run-dev - Run EOS development server in virtual environment (automatically reloads)."
|
@echo " run-dev - Run EOS development server in the virtual environment (automatically reloads)."
|
||||||
@echo " run-dash - Run EOSdash production server in virtual environment."
|
|
||||||
@echo " run-dash-dev - Run EOSdash development server in virtual environment (automatically reloads)."
|
|
||||||
@echo " dist - Create distribution (in dist/)."
|
@echo " dist - Create distribution (in dist/)."
|
||||||
@echo " clean - Remove generated documentation, distribution and virtual environment."
|
@echo " clean - Remove generated documentation, distribution and virtual environment."
|
||||||
|
|
||||||
@@ -87,19 +85,11 @@ clean: clean-docs
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
@echo "Starting EOS production server, please wait..."
|
@echo "Starting EOS production server, please wait..."
|
||||||
.venv/bin/python -m akkudoktoreos.server.eos
|
.venv/bin/python src/akkudoktoreos/server/eos.py
|
||||||
|
|
||||||
run-dev:
|
run-dev:
|
||||||
@echo "Starting EOS development server, please wait..."
|
@echo "Starting EOS development server, please wait..."
|
||||||
.venv/bin/python -m akkudoktoreos.server.eos --host localhost --port 8503 --reload true
|
.venv/bin/python src/akkudoktoreos/server/eos.py --host localhost --port 8503 --reload true
|
||||||
|
|
||||||
run-dash:
|
|
||||||
@echo "Starting EOSdash production server, please wait..."
|
|
||||||
.venv/bin/python -m akkudoktoreos.server.eosdash
|
|
||||||
|
|
||||||
run-dash-dev:
|
|
||||||
@echo "Starting EOSdash development server, please wait..."
|
|
||||||
.venv/bin/python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --reload true
|
|
||||||
|
|
||||||
# Target to setup tests.
|
# Target to setup tests.
|
||||||
test-setup: pip-dev
|
test-setup: pip-dev
|
||||||
|
@@ -23,15 +23,13 @@ Linux:
|
|||||||
```bash
|
```bash
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.venv/bin/pip install -r requirements.txt
|
||||||
.venv/bin/pip install -e .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv\Scripts\pip install -r requirements.txt
|
.venv\Scripts\pip install -r requirements.txt
|
||||||
.venv\Scripts\pip install -e .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, start the EOS server:
|
Finally, start the EOS server:
|
||||||
|
@@ -11,6 +11,12 @@ services:
|
|||||||
dockerfile: "Dockerfile"
|
dockerfile: "Dockerfile"
|
||||||
args:
|
args:
|
||||||
PYTHON_VERSION: "${PYTHON_VERSION}"
|
PYTHON_VERSION: "${PYTHON_VERSION}"
|
||||||
|
BASE_IMAGE: "${BASE_IMAGE}"
|
||||||
|
IMAGE_SUFFIX: "${IMAGE_SUFFIX}"
|
||||||
|
APT_PACKAGES: "${APT_PACKAGES:-}"
|
||||||
|
APT_BUILD_PACKAGES: "${APT_BUILD_PACKAGES:-}"
|
||||||
|
PIP_EXTRA_INDEX_URL: "${PIP_EXTRA_INDEX_URL:-}"
|
||||||
|
RUSTUP_INSTALL: "${RUSTUP_INSTALL:-}"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
@@ -27,10 +27,12 @@ Validators:
|
|||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| data_folder_path | `EOS_GENERAL__DATA_FOLDER_PATH` | `Optional[pathlib.Path]` | `rw` | `None` | Path to EOS data directory. |
|
| data_folder_path | `EOS_GENERAL__DATA_FOLDER_PATH` | `Optional[pathlib.Path]` | `rw` | `None` | Path to EOS data directory. |
|
||||||
| data_output_subpath | `EOS_GENERAL__DATA_OUTPUT_SUBPATH` | `Optional[pathlib.Path]` | `rw` | `output` | Sub-path for the EOS output data directory. |
|
| data_output_subpath | `EOS_GENERAL__DATA_OUTPUT_SUBPATH` | `Optional[pathlib.Path]` | `rw` | `output` | Sub-path for the EOS output data directory. |
|
||||||
|
| data_cache_subpath | `EOS_GENERAL__DATA_CACHE_SUBPATH` | `Optional[pathlib.Path]` | `rw` | `cache` | Sub-path for the EOS cache data directory. |
|
||||||
| latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°) |
|
| latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°) |
|
||||||
| longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees, within -180 to 180 (°) |
|
| longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees, within -180 to 180 (°) |
|
||||||
| timezone | | `Optional[str]` | `ro` | `N/A` | Compute timezone based on latitude and longitude. |
|
| timezone | | `Optional[str]` | `ro` | `N/A` | Compute timezone based on latitude and longitude. |
|
||||||
| data_output_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Compute data_output_path based on data_folder_path. |
|
| data_output_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Compute data_output_path based on data_folder_path. |
|
||||||
|
| data_cache_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Compute data_cache_path based on data_folder_path. |
|
||||||
| config_folder_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration directory. |
|
| config_folder_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration directory. |
|
||||||
| config_file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration file. |
|
| config_file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration file. |
|
||||||
:::
|
:::
|
||||||
@@ -44,6 +46,7 @@ Validators:
|
|||||||
"general": {
|
"general": {
|
||||||
"data_folder_path": null,
|
"data_folder_path": null,
|
||||||
"data_output_subpath": "output",
|
"data_output_subpath": "output",
|
||||||
|
"data_cache_subpath": "cache",
|
||||||
"latitude": 52.52,
|
"latitude": 52.52,
|
||||||
"longitude": 13.405
|
"longitude": 13.405
|
||||||
}
|
}
|
||||||
@@ -59,66 +62,18 @@ Validators:
|
|||||||
"general": {
|
"general": {
|
||||||
"data_folder_path": null,
|
"data_folder_path": null,
|
||||||
"data_output_subpath": "output",
|
"data_output_subpath": "output",
|
||||||
|
"data_cache_subpath": "cache",
|
||||||
"latitude": 52.52,
|
"latitude": 52.52,
|
||||||
"longitude": 13.405,
|
"longitude": 13.405,
|
||||||
"timezone": "Europe/Berlin",
|
"timezone": "Europe/Berlin",
|
||||||
"data_output_path": null,
|
"data_output_path": null,
|
||||||
|
"data_cache_path": null,
|
||||||
"config_folder_path": "/home/user/.config/net.akkudoktoreos.net",
|
"config_folder_path": "/home/user/.config/net.akkudoktoreos.net",
|
||||||
"config_file_path": "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
|
"config_file_path": "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cache Configuration
|
|
||||||
|
|
||||||
:::{table} cache
|
|
||||||
:widths: 10 20 10 5 5 30
|
|
||||||
:align: left
|
|
||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
|
||||||
| subpath | `EOS_CACHE__SUBPATH` | `Optional[pathlib.Path]` | `rw` | `cache` | Sub-path for the EOS cache data directory. |
|
|
||||||
| cleanup_interval | `EOS_CACHE__CLEANUP_INTERVAL` | `float` | `rw` | `300` | Intervall in seconds for EOS file cache cleanup. |
|
|
||||||
:::
|
|
||||||
|
|
||||||
### Example Input/Output
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
"cache": {
|
|
||||||
"subpath": "cache",
|
|
||||||
"cleanup_interval": 300.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Energy Management Configuration
|
|
||||||
|
|
||||||
:::{table} ems
|
|
||||||
:widths: 10 20 10 5 5 30
|
|
||||||
:align: left
|
|
||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
|
||||||
| startup_delay | `EOS_EMS__STARTUP_DELAY` | `float` | `rw` | `5` | Startup delay in seconds for EOS energy management runs. |
|
|
||||||
| interval | `EOS_EMS__INTERVAL` | `Optional[float]` | `rw` | `None` | Intervall in seconds between EOS energy management runs. |
|
|
||||||
:::
|
|
||||||
|
|
||||||
### Example Input/Output
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
"ems": {
|
|
||||||
"startup_delay": 5.0,
|
|
||||||
"interval": 300.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logging Configuration
|
## Logging Configuration
|
||||||
|
|
||||||
:::{table} logging
|
:::{table} logging
|
||||||
@@ -871,6 +826,9 @@ Validators:
|
|||||||
|
|
||||||
## Server Configuration
|
## Server Configuration
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
To be added
|
||||||
|
|
||||||
:::{table} server
|
:::{table} server
|
||||||
:widths: 10 20 10 5 5 30
|
:widths: 10 20 10 5 5 30
|
||||||
:align: left
|
:align: left
|
||||||
@@ -931,17 +889,10 @@ Validators:
|
|||||||
"general": {
|
"general": {
|
||||||
"data_folder_path": null,
|
"data_folder_path": null,
|
||||||
"data_output_subpath": "output",
|
"data_output_subpath": "output",
|
||||||
|
"data_cache_subpath": "cache",
|
||||||
"latitude": 52.52,
|
"latitude": 52.52,
|
||||||
"longitude": 13.405
|
"longitude": 13.405
|
||||||
},
|
},
|
||||||
"cache": {
|
|
||||||
"subpath": "cache",
|
|
||||||
"cleanup_interval": 300.0
|
|
||||||
},
|
|
||||||
"ems": {
|
|
||||||
"startup_delay": 5.0,
|
|
||||||
"interval": 300.0
|
|
||||||
},
|
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "INFO"
|
"level": "INFO"
|
||||||
},
|
},
|
||||||
|
@@ -166,127 +166,6 @@ Note:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## GET /v1/admin/cache
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_get_v1_admin_cache_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_get_v1_admin_cache_get)
|
|
||||||
|
|
||||||
Fastapi Admin Cache Get
|
|
||||||
|
|
||||||
```
|
|
||||||
Current cache management data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): The management data.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## POST /v1/admin/cache/clear
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_clear_post_v1_admin_cache_clear_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_clear_post_v1_admin_cache_clear_post)
|
|
||||||
|
|
||||||
Fastapi Admin Cache Clear Post
|
|
||||||
|
|
||||||
```
|
|
||||||
Clear the cache from expired data.
|
|
||||||
|
|
||||||
Deletes expired cache files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
clear_all (Optional[bool]): Delete all cached files. Default is False.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): The management data after cleanup.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
|
|
||||||
- `clear_all` (query, optional): No description provided.
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
- **422**: Validation Error
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## POST /v1/admin/cache/load
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_load_post_v1_admin_cache_load_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_load_post_v1_admin_cache_load_post)
|
|
||||||
|
|
||||||
Fastapi Admin Cache Load Post
|
|
||||||
|
|
||||||
```
|
|
||||||
Load cache management data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): The management data that was loaded.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## POST /v1/admin/cache/save
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_cache_save_post_v1_admin_cache_save_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_cache_save_post_v1_admin_cache_save_post)
|
|
||||||
|
|
||||||
Fastapi Admin Cache Save Post
|
|
||||||
|
|
||||||
```
|
|
||||||
Save the current cache management data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): The management data that was saved.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## POST /v1/admin/server/restart
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_server_restart_post_v1_admin_server_restart_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_server_restart_post_v1_admin_server_restart_post)
|
|
||||||
|
|
||||||
Fastapi Admin Server Restart Post
|
|
||||||
|
|
||||||
```
|
|
||||||
Restart the server.
|
|
||||||
|
|
||||||
Restart EOS properly by starting a new instance before exiting the old one.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## POST /v1/admin/server/shutdown
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_admin_server_shutdown_post_v1_admin_server_shutdown_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_admin_server_shutdown_post_v1_admin_server_shutdown_post)
|
|
||||||
|
|
||||||
Fastapi Admin Server Shutdown Post
|
|
||||||
|
|
||||||
```
|
|
||||||
Shutdown the server.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GET /v1/config
|
## GET /v1/config
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_v1_config_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_v1_config_get)
|
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_v1_config_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_v1_config_get)
|
||||||
@@ -359,11 +238,11 @@ Returns:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## POST /v1/config/reset
|
## PUT /v1/config/reset
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_reset_post_v1_config_reset_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_reset_post_v1_config_reset_post)
|
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_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 Reset Post
|
Fastapi Config Update Post
|
||||||
|
|
||||||
```
|
```
|
||||||
Reset the configuration to the EOS configuration file.
|
Reset the configuration to the EOS configuration file.
|
||||||
@@ -378,86 +257,6 @@ Returns:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## GET /v1/config/{path}
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_key_v1_config__path__get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_key_v1_config__path__get)
|
|
||||||
|
|
||||||
Fastapi Config Get Key
|
|
||||||
|
|
||||||
```
|
|
||||||
Get the value of a nested key or index in the config model.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
value (Any): The value of the selected nested key.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
|
|
||||||
- `path` (path, required): The nested path to the configuration key (e.g., general/latitude).
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
- **422**: Validation Error
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PUT /v1/config/{path}
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_put_key_v1_config__path__put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_put_key_v1_config__path__put)
|
|
||||||
|
|
||||||
Fastapi Config Put Key
|
|
||||||
|
|
||||||
```
|
|
||||||
Update a nested key or index in the config model.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
|
|
||||||
value (Any): The new value to assign to the key or index at path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
configuration (ConfigEOS): The current configuration after the update.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
|
|
||||||
- `path` (path, required): The nested path to the configuration key (e.g., general/latitude).
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
|
|
||||||
- `application/json`: {
|
|
||||||
"description": "The value to assign to the specified configuration path.",
|
|
||||||
"title": "Value"
|
|
||||||
}
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
- **422**: Validation Error
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GET /v1/health
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_health_get_v1_health_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_health_get_v1_health_get)
|
|
||||||
|
|
||||||
Fastapi Health Get
|
|
||||||
|
|
||||||
```
|
|
||||||
Health check endpoint to verify that the EOS server is alive.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PUT /v1/measurement/data
|
## PUT /v1/measurement/data
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put)
|
**Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put)
|
||||||
@@ -674,92 +473,6 @@ Merge the measurement of given key and value into EOS measurements at given date
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## GET /v1/prediction/dataframe
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_dataframe_get_v1_prediction_dataframe_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_dataframe_get_v1_prediction_dataframe_get)
|
|
||||||
|
|
||||||
Fastapi Prediction Dataframe Get
|
|
||||||
|
|
||||||
```
|
|
||||||
Get prediction for given key within given date range as series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key (str): Prediction key
|
|
||||||
start_datetime (Optional[str]): Starting datetime (inclusive).
|
|
||||||
Defaults to start datetime of latest prediction.
|
|
||||||
end_datetime (Optional[str]: Ending datetime (exclusive).
|
|
||||||
|
|
||||||
Defaults to end datetime of latest prediction.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
|
|
||||||
- `keys` (query, required): Prediction keys.
|
|
||||||
|
|
||||||
- `start_datetime` (query, optional): Starting datetime (inclusive).
|
|
||||||
|
|
||||||
- `end_datetime` (query, optional): Ending datetime (exclusive).
|
|
||||||
|
|
||||||
- `interval` (query, optional): Time duration for each interval. Defaults to 1 hour.
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
- **422**: Validation Error
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PUT /v1/prediction/import/{provider_id}
|
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_import_provider_v1_prediction_import__provider_id__put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_import_provider_v1_prediction_import__provider_id__put)
|
|
||||||
|
|
||||||
Fastapi Prediction Import Provider
|
|
||||||
|
|
||||||
```
|
|
||||||
Import prediction for given provider ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_id: ID of provider to update.
|
|
||||||
data: Prediction data.
|
|
||||||
force_enable: Update data even if provider is disabled.
|
|
||||||
Defaults to False.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
|
|
||||||
- `provider_id` (path, required): Provider ID.
|
|
||||||
|
|
||||||
- `force_enable` (query, optional): No description provided.
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
|
|
||||||
- `application/json`: {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/PydanticDateTimeDataFrame"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/PydanticDateTimeData"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Data"
|
|
||||||
}
|
|
||||||
|
|
||||||
**Responses**:
|
|
||||||
|
|
||||||
- **200**: Successful Response
|
|
||||||
|
|
||||||
- **422**: Validation Error
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GET /v1/prediction/keys
|
## GET /v1/prediction/keys
|
||||||
|
|
||||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_keys_get_v1_prediction_keys_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_keys_get_v1_prediction_keys_get)
|
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_keys_get_v1_prediction_keys_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_keys_get_v1_prediction_keys_get)
|
||||||
@@ -803,7 +516,7 @@ Args:
|
|||||||
|
|
||||||
- `end_datetime` (query, optional): Ending datetime (exclusive).
|
- `end_datetime` (query, optional): Ending datetime (exclusive).
|
||||||
|
|
||||||
- `interval` (query, optional): Time duration for each interval. Defaults to 1 hour.
|
- `interval` (query, optional): Time duration for each interval.
|
||||||
|
|
||||||
**Responses**:
|
**Responses**:
|
||||||
|
|
||||||
|
@@ -141,12 +141,9 @@ The prediction key for the electricity price forecast data is:
|
|||||||
- `elecprice_marketprice_wh`: Electricity market price per Wh (€/Wh).
|
- `elecprice_marketprice_wh`: Electricity market price per Wh (€/Wh).
|
||||||
|
|
||||||
The electricity proce forecast data must be provided in one of the formats described in
|
The electricity proce forecast data must be provided in one of the formats described in
|
||||||
<project:#prediction-import-providers>. The data source can be given in the
|
<project:#prediction-import-providers>. The data source must be given in the
|
||||||
`import_file_path` or `import_json` configuration option.
|
`import_file_path` or `import_json` configuration option.
|
||||||
|
|
||||||
The data may additionally or solely be provided by the
|
|
||||||
**PUT** `/v1/prediction/import/ElecPriceImport` endpoint.
|
|
||||||
|
|
||||||
## Load Prediction
|
## Load Prediction
|
||||||
|
|
||||||
Prediction keys:
|
Prediction keys:
|
||||||
@@ -187,12 +184,9 @@ The prediction keys for the load forecast data are:
|
|||||||
- `load_mean_adjusted`: Predicted load mean value adjusted by load measurement (W).
|
- `load_mean_adjusted`: Predicted load mean value adjusted by load measurement (W).
|
||||||
|
|
||||||
The load forecast data must be provided in one of the formats described in
|
The load forecast data must be provided in one of the formats described in
|
||||||
<project:#prediction-import-providers>. The data source can be given in the `loadimport_file_path`
|
<project:#prediction-import-providers>. The data source must be given in the `loadimport_file_path`
|
||||||
or `loadimport_json` configuration option.
|
or `loadimport_json` configuration option.
|
||||||
|
|
||||||
The data may additionally or solely be provided by the
|
|
||||||
**PUT** `/v1/prediction/import/LoadImport` endpoint.
|
|
||||||
|
|
||||||
## PV Power Prediction
|
## PV Power Prediction
|
||||||
|
|
||||||
Prediction keys:
|
Prediction keys:
|
||||||
@@ -368,12 +362,9 @@ The prediction keys for the PV forecast data are:
|
|||||||
- `pvforecast_dc_power`: Total AC power (W).
|
- `pvforecast_dc_power`: Total AC power (W).
|
||||||
|
|
||||||
The PV forecast data must be provided in one of the formats described in
|
The PV forecast data must be provided in one of the formats described in
|
||||||
<project:#prediction-import-providers>. The data source can be given in the
|
<project:#prediction-import-providers>. The data source must be given in the
|
||||||
`import_file_path` or `import_json` configuration option.
|
`import_file_path` or `import_json` configuration option.
|
||||||
|
|
||||||
The data may additionally or solely be provided by the
|
|
||||||
**PUT** `/v1/prediction/import/PVForecastImport` endpoint.
|
|
||||||
|
|
||||||
## Weather Prediction
|
## Weather Prediction
|
||||||
|
|
||||||
Prediction keys:
|
Prediction keys:
|
||||||
@@ -469,7 +460,7 @@ The `WeatherImport` provider is designed to import weather forecast data from a
|
|||||||
string. An external entity should update the file or JSON string whenever new prediction data
|
string. An external entity should update the file or JSON string whenever new prediction data
|
||||||
becomes available.
|
becomes available.
|
||||||
|
|
||||||
The prediction keys for the weather forecast data are:
|
The prediction keys for the PV forecast data are:
|
||||||
|
|
||||||
- `weather_dew_point`: Dew Point (°C)
|
- `weather_dew_point`: Dew Point (°C)
|
||||||
- `weather_dhi`: Diffuse Horizontal Irradiance (W/m2)
|
- `weather_dhi`: Diffuse Horizontal Irradiance (W/m2)
|
||||||
@@ -495,8 +486,5 @@ The prediction keys for the weather forecast data are:
|
|||||||
- `weather_wind_speed`: Wind Speed (kmph)
|
- `weather_wind_speed`: Wind Speed (kmph)
|
||||||
|
|
||||||
The PV forecast data must be provided in one of the formats described in
|
The PV forecast data must be provided in one of the formats described in
|
||||||
<project:#prediction-import-providers>. The data source can be given in the
|
<project:#prediction-import-providers>. The data source must be given in the
|
||||||
`import_file_path` or `import_json` configuration option.
|
`import_file_path` or `import_json` configuration option.
|
||||||
|
|
||||||
The data may additionally or solely be provided by the
|
|
||||||
**PUT** `/v1/prediction/import/WeatherImport` endpoint.
|
|
||||||
|
@@ -19,7 +19,6 @@ Install the dependencies in a virtual environment:
|
|||||||
|
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv\Scripts\pip install -r requirements.txt
|
.venv\Scripts\pip install -r requirements.txt
|
||||||
.venv\Scripts\pip install -e .
|
|
||||||
|
|
||||||
.. tab:: Linux
|
.. tab:: Linux
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ Install the dependencies in a virtual environment:
|
|||||||
|
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.venv/bin/pip install -r requirements.txt
|
||||||
.venv/bin/pip install -e .
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
1037
openapi.json
@@ -1,5 +1,6 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
gitpython==3.1.44
|
gitpython==3.1.44
|
||||||
|
linkify-it-py==2.0.3
|
||||||
myst-parser==4.0.0
|
myst-parser==4.0.0
|
||||||
sphinx==8.1.3
|
sphinx==8.1.3
|
||||||
sphinx_rtd_theme==3.0.2
|
sphinx_rtd_theme==3.0.2
|
||||||
|
@@ -1,13 +1,8 @@
|
|||||||
cachebox==4.4.2
|
|
||||||
numpy==2.2.2
|
numpy==2.2.2
|
||||||
numpydantic==1.6.7
|
numpydantic==1.6.7
|
||||||
matplotlib==3.10.0
|
matplotlib==3.10.0
|
||||||
fastapi[standard]==0.115.7
|
fastapi[standard]==0.115.7
|
||||||
python-fasthtml==0.12.0
|
python-fasthtml==0.12.0
|
||||||
MonsterUI==0.0.29
|
|
||||||
markdown-it-py==3.0.0
|
|
||||||
mdit-py-plugins==0.4.2
|
|
||||||
bokeh==3.6.3
|
|
||||||
uvicorn==0.34.0
|
uvicorn==0.34.0
|
||||||
scikit-learn==1.6.1
|
scikit-learn==1.6.1
|
||||||
timezonefinder==6.5.8
|
timezonefinder==6.5.8
|
||||||
@@ -16,9 +11,7 @@ requests==2.32.3
|
|||||||
pandas==2.2.3
|
pandas==2.2.3
|
||||||
pendulum==3.0.0
|
pendulum==3.0.0
|
||||||
platformdirs==4.3.6
|
platformdirs==4.3.6
|
||||||
psutil==6.1.1
|
|
||||||
pvlib==0.11.2
|
pvlib==0.11.2
|
||||||
pydantic==2.10.6
|
pydantic==2.10.6
|
||||||
statsmodels==0.14.4
|
statsmodels==0.14.4
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
linkify-it-py==2.0.3
|
|
||||||
|
@@ -150,7 +150,7 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if args.input_file:
|
if args.input_file:
|
||||||
with open(args.input_file, "r", encoding="utf-8", newline=None) as f:
|
with open(args.input_file, "r", encoding="utf8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
elif args.input:
|
elif args.input:
|
||||||
content = args.input
|
content = args.input
|
||||||
@@ -164,7 +164,7 @@ def main():
|
|||||||
)
|
)
|
||||||
if args.output_file:
|
if args.output_file:
|
||||||
# Write to file
|
# Write to file
|
||||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
with open(args.output_file, "w", encoding="utf8") as f:
|
||||||
f.write(extracted_content)
|
f.write(extracted_content)
|
||||||
else:
|
else:
|
||||||
# Write to std output
|
# Write to std output
|
||||||
|
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -87,7 +86,7 @@ def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_f
|
|||||||
|
|
||||||
|
|
||||||
def get_type_name(field_type: type) -> str:
|
def get_type_name(field_type: type) -> str:
|
||||||
type_name = str(field_type).replace("typing.", "").replace("pathlib._local", "pathlib")
|
type_name = str(field_type).replace("typing.", "")
|
||||||
if type_name.startswith("<class"):
|
if type_name.startswith("<class"):
|
||||||
type_name = field_type.__name__
|
type_name = field_type.__name__
|
||||||
return type_name
|
return type_name
|
||||||
@@ -297,11 +296,9 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
config_md = generate_config_md(config_eos)
|
config_md = generate_config_md(config_eos)
|
||||||
if os.name == "nt":
|
|
||||||
config_md = config_md.replace("127.0.0.1", "0.0.0.0").replace("\\\\", "/")
|
|
||||||
if args.output_file:
|
if args.output_file:
|
||||||
# Write to file
|
# Write to file
|
||||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
with open(args.output_file, "w", encoding="utf8") as f:
|
||||||
f.write(config_md)
|
f.write(config_md)
|
||||||
else:
|
else:
|
||||||
# Write to std output
|
# Write to std output
|
||||||
|
@@ -16,7 +16,6 @@ Example:
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from fastapi.openapi.utils import get_openapi
|
from fastapi.openapi.utils import get_openapi
|
||||||
@@ -58,11 +57,9 @@ def main():
|
|||||||
try:
|
try:
|
||||||
openapi_spec = generate_openapi()
|
openapi_spec = generate_openapi()
|
||||||
openapi_spec_str = json.dumps(openapi_spec, indent=2)
|
openapi_spec_str = json.dumps(openapi_spec, indent=2)
|
||||||
if os.name == "nt":
|
|
||||||
openapi_spec_str = openapi_spec_str.replace("127.0.0.1", "0.0.0.0")
|
|
||||||
if args.output_file:
|
if args.output_file:
|
||||||
# Write to file
|
# Write to file
|
||||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
with open(args.output_file, "w", encoding="utf8") as f:
|
||||||
f.write(openapi_spec_str)
|
f.write(openapi_spec_str)
|
||||||
else:
|
else:
|
||||||
# Write to std output
|
# Write to std output
|
||||||
|
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import git
|
import git
|
||||||
@@ -285,11 +284,9 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
openapi_md = generate_openapi_md()
|
openapi_md = generate_openapi_md()
|
||||||
if os.name == "nt":
|
|
||||||
openapi_md = openapi_md.replace("127.0.0.1", "0.0.0.0")
|
|
||||||
if args.output_file:
|
if args.output_file:
|
||||||
# Write to file
|
# Write to file
|
||||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
with open(args.output_file, "w", encoding="utf8") as f:
|
||||||
f.write(openapi_md)
|
f.write(openapi_md)
|
||||||
else:
|
else:
|
||||||
# Write to std output
|
# Write to std output
|
||||||
|
@@ -121,40 +121,30 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
|
|||||||
# Initialize the oprediction
|
# Initialize the oprediction
|
||||||
config_eos = get_config()
|
config_eos = get_config()
|
||||||
prediction_eos = get_prediction()
|
prediction_eos = get_prediction()
|
||||||
|
if verbose:
|
||||||
|
print(f"\nProvider ID: {provider_id}")
|
||||||
if provider_id in ("PVForecastAkkudoktor",):
|
if provider_id in ("PVForecastAkkudoktor",):
|
||||||
settings = config_pvforecast()
|
settings = config_pvforecast()
|
||||||
forecast = "pvforecast"
|
settings["pvforecast"]["provider"] = provider_id
|
||||||
elif provider_id in ("BrightSky", "ClearOutside"):
|
elif provider_id in ("BrightSky", "ClearOutside"):
|
||||||
settings = config_weather()
|
settings = config_weather()
|
||||||
forecast = "weather"
|
settings["weather"]["provider"] = provider_id
|
||||||
elif provider_id in ("ElecPriceAkkudoktor",):
|
elif provider_id in ("ElecPriceAkkudoktor",):
|
||||||
settings = config_elecprice()
|
settings = config_elecprice()
|
||||||
forecast = "elecprice"
|
settings["elecprice"]["provider"] = provider_id
|
||||||
elif provider_id in ("LoadAkkudoktor",):
|
elif provider_id in ("LoadAkkudoktor",):
|
||||||
settings = config_elecprice()
|
settings = config_elecprice()
|
||||||
forecast = "load"
|
|
||||||
settings["load"]["loadakkudoktor_year_energy"] = 1000
|
settings["load"]["loadakkudoktor_year_energy"] = 1000
|
||||||
|
settings["load"]["provider"] = provider_id
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown provider '{provider_id}'.")
|
raise ValueError(f"Unknown provider '{provider_id}'.")
|
||||||
settings[forecast]["provider"] = provider_id
|
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
|
|
||||||
provider = prediction_eos.provider_by_id(provider_id)
|
|
||||||
|
|
||||||
prediction_eos.update_data()
|
prediction_eos.update_data()
|
||||||
|
|
||||||
# Return result of prediction
|
# Return result of prediction
|
||||||
|
provider = prediction_eos.provider_by_id(provider_id)
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f"\nProvider ID: {provider.provider_id()}")
|
|
||||||
print("----------")
|
|
||||||
print("\nSettings\n----------")
|
|
||||||
print(settings)
|
|
||||||
print("\nProvider\n----------")
|
|
||||||
print(f"elecprice.provider: {config_eos.elecprice.provider}")
|
|
||||||
print(f"load.provider: {config_eos.load.provider}")
|
|
||||||
print(f"pvforecast.provider: {config_eos.pvforecast.provider}")
|
|
||||||
print(f"weather.provider: {config_eos.weather.provider}")
|
|
||||||
print(f"enabled: {provider.enabled()}")
|
|
||||||
for key in provider.record_keys:
|
for key in provider.record_keys:
|
||||||
print(f"\n{key}\n----------")
|
print(f"\n{key}\n----------")
|
||||||
print(f"Array: {provider.key_to_array(key)}")
|
print(f"Array: {provider.key_to_array(key)}")
|
||||||
|
@@ -22,16 +22,15 @@ from pydantic_settings import (
|
|||||||
PydanticBaseSettingsSource,
|
PydanticBaseSettingsSource,
|
||||||
SettingsConfigDict,
|
SettingsConfigDict,
|
||||||
)
|
)
|
||||||
|
from pydantic_settings.sources import ConfigFileSourceMixin
|
||||||
|
|
||||||
# settings
|
# settings
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.cachesettings import CacheCommonSettings
|
|
||||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||||
from akkudoktoreos.core.decorators import classproperty
|
from akkudoktoreos.core.decorators import classproperty
|
||||||
from akkudoktoreos.core.emsettings import EnergyManagementCommonSettings
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.logsettings import LoggingCommonSettings
|
from akkudoktoreos.core.logsettings import LoggingCommonSettings
|
||||||
from akkudoktoreos.core.pydantic import access_nested_value, merge_models
|
from akkudoktoreos.core.pydantic import merge_models
|
||||||
from akkudoktoreos.devices.settings import DevicesCommonSettings
|
from akkudoktoreos.devices.settings import DevicesCommonSettings
|
||||||
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
|
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
|
||||||
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
|
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
|
||||||
@@ -97,6 +96,10 @@ class GeneralSettings(SettingsBaseModel):
|
|||||||
default="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(
|
||||||
|
default="cache", description="Sub-path for the EOS cache data directory."
|
||||||
|
)
|
||||||
|
|
||||||
latitude: Optional[float] = Field(
|
latitude: Optional[float] = Field(
|
||||||
default=52.52,
|
default=52.52,
|
||||||
ge=-90.0,
|
ge=-90.0,
|
||||||
@@ -125,6 +128,12 @@ class GeneralSettings(SettingsBaseModel):
|
|||||||
"""Compute data_output_path based on data_folder_path."""
|
"""Compute data_output_path based on data_folder_path."""
|
||||||
return get_absolute_path(self.data_folder_path, self.data_output_subpath)
|
return get_absolute_path(self.data_folder_path, self.data_output_subpath)
|
||||||
|
|
||||||
|
@computed_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]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
def config_folder_path(self) -> Optional[Path]:
|
def config_folder_path(self) -> Optional[Path]:
|
||||||
@@ -144,62 +153,18 @@ class SettingsEOS(BaseSettings):
|
|||||||
Used by updating the configuration with specific settings only.
|
Used by updating the configuration with specific settings only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
general: Optional[GeneralSettings] = Field(
|
general: Optional[GeneralSettings] = None
|
||||||
default=None,
|
logging: Optional[LoggingCommonSettings] = None
|
||||||
description="General Settings",
|
devices: Optional[DevicesCommonSettings] = None
|
||||||
)
|
measurement: Optional[MeasurementCommonSettings] = None
|
||||||
cache: Optional[CacheCommonSettings] = Field(
|
optimization: Optional[OptimizationCommonSettings] = None
|
||||||
default=None,
|
prediction: Optional[PredictionCommonSettings] = None
|
||||||
description="Cache Settings",
|
elecprice: Optional[ElecPriceCommonSettings] = None
|
||||||
)
|
load: Optional[LoadCommonSettings] = None
|
||||||
ems: Optional[EnergyManagementCommonSettings] = Field(
|
pvforecast: Optional[PVForecastCommonSettings] = None
|
||||||
default=None,
|
weather: Optional[WeatherCommonSettings] = None
|
||||||
description="Energy Management Settings",
|
server: Optional[ServerCommonSettings] = None
|
||||||
)
|
utils: Optional[UtilsCommonSettings] = None
|
||||||
logging: Optional[LoggingCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Logging Settings",
|
|
||||||
)
|
|
||||||
devices: Optional[DevicesCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Devices Settings",
|
|
||||||
)
|
|
||||||
measurement: Optional[MeasurementCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Measurement Settings",
|
|
||||||
)
|
|
||||||
optimization: Optional[OptimizationCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optimization Settings",
|
|
||||||
)
|
|
||||||
prediction: Optional[PredictionCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Prediction Settings",
|
|
||||||
)
|
|
||||||
elecprice: Optional[ElecPriceCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Electricity Price Settings",
|
|
||||||
)
|
|
||||||
load: Optional[LoadCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Load Settings",
|
|
||||||
)
|
|
||||||
pvforecast: Optional[PVForecastCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="PV Forecast Settings",
|
|
||||||
)
|
|
||||||
weather: Optional[WeatherCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Weather Settings",
|
|
||||||
)
|
|
||||||
server: Optional[ServerCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Server Settings",
|
|
||||||
)
|
|
||||||
utils: Optional[UtilsCommonSettings] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Utilities Settings",
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_nested_delimiter="__",
|
env_nested_delimiter="__",
|
||||||
@@ -216,8 +181,6 @@ class SettingsEOSDefaults(SettingsEOS):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
general: GeneralSettings = GeneralSettings()
|
general: GeneralSettings = GeneralSettings()
|
||||||
cache: CacheCommonSettings = CacheCommonSettings()
|
|
||||||
ems: EnergyManagementCommonSettings = EnergyManagementCommonSettings()
|
|
||||||
logging: LoggingCommonSettings = LoggingCommonSettings()
|
logging: LoggingCommonSettings = LoggingCommonSettings()
|
||||||
devices: DevicesCommonSettings = DevicesCommonSettings()
|
devices: DevicesCommonSettings = DevicesCommonSettings()
|
||||||
measurement: MeasurementCommonSettings = MeasurementCommonSettings()
|
measurement: MeasurementCommonSettings = MeasurementCommonSettings()
|
||||||
@@ -321,13 +284,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
- This method logs a warning if the default configuration file cannot be copied.
|
- 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.
|
- It ensures that a fallback to the default configuration file is always possible.
|
||||||
"""
|
"""
|
||||||
setting_sources = [
|
file_settings: Optional[ConfigFileSourceMixin] = None
|
||||||
init_settings,
|
|
||||||
env_settings,
|
|
||||||
dotenv_settings,
|
|
||||||
]
|
|
||||||
|
|
||||||
file_settings: Optional[JsonConfigSettingsSource] = None
|
|
||||||
config_file, exists = cls._get_config_file_path()
|
config_file, exists = cls._get_config_file_path()
|
||||||
config_dir = config_file.parent
|
config_dir = config_file.parent
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -338,21 +295,20 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
logger.warning(f"Could not copy default config: {exc}. Using default config...")
|
logger.warning(f"Could not copy default config: {exc}. Using default config...")
|
||||||
config_file = cls.config_default_file_path
|
config_file = cls.config_default_file_path
|
||||||
config_dir = config_file.parent
|
config_dir = config_file.parent
|
||||||
try:
|
file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file)
|
||||||
file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file)
|
|
||||||
setting_sources.append(file_settings)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error reading config file '{config_file}' (falling back to default config): {e}"
|
|
||||||
)
|
|
||||||
default_settings = JsonConfigSettingsSource(
|
default_settings = JsonConfigSettingsSource(
|
||||||
settings_cls, json_file=cls.config_default_file_path
|
settings_cls, json_file=cls.config_default_file_path
|
||||||
)
|
)
|
||||||
GeneralSettings._config_folder_path = config_dir
|
GeneralSettings._config_folder_path = config_dir
|
||||||
GeneralSettings._config_file_path = config_file
|
GeneralSettings._config_file_path = config_file
|
||||||
|
|
||||||
setting_sources.append(default_settings)
|
return (
|
||||||
return tuple(setting_sources)
|
init_settings,
|
||||||
|
env_settings,
|
||||||
|
dotenv_settings,
|
||||||
|
file_settings,
|
||||||
|
default_settings,
|
||||||
|
)
|
||||||
|
|
||||||
@classproperty
|
@classproperty
|
||||||
def config_default_file_path(cls) -> Path:
|
def config_default_file_path(cls) -> Path:
|
||||||
@@ -372,15 +328,13 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
"""
|
"""
|
||||||
if hasattr(self, "_initialized"):
|
if hasattr(self, "_initialized"):
|
||||||
return
|
return
|
||||||
self._setup(self, *args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self._create_initial_config_file()
|
||||||
|
self._update_data_folder_path()
|
||||||
|
|
||||||
def _setup(self, *args: Any, **kwargs: Any) -> None:
|
def _setup(self, *args: Any, **kwargs: Any) -> None:
|
||||||
"""Re-initialize global settings."""
|
"""Re-initialize global settings."""
|
||||||
# Assure settings base knows EOS configuration
|
|
||||||
SettingsBaseModel.config = self
|
|
||||||
# (Re-)load settings
|
|
||||||
SettingsEOSDefaults.__init__(self, *args, **kwargs)
|
SettingsEOSDefaults.__init__(self, *args, **kwargs)
|
||||||
# Init config file and data folder pathes
|
|
||||||
self._create_initial_config_file()
|
self._create_initial_config_file()
|
||||||
self._update_data_folder_path()
|
self._update_data_folder_path()
|
||||||
|
|
||||||
@@ -426,37 +380,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
"""
|
"""
|
||||||
self._setup()
|
self._setup()
|
||||||
|
|
||||||
def set_config_value(self, path: str, value: Any) -> None:
|
|
||||||
"""Set a configuration value based on the provided path.
|
|
||||||
|
|
||||||
Supports string paths (with '/' separators) or sequence paths (list/tuple).
|
|
||||||
Trims leading and trailing '/' from string paths.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
|
|
||||||
value (Any]): The value to set.
|
|
||||||
"""
|
|
||||||
access_nested_value(self, path, True, value)
|
|
||||||
|
|
||||||
def get_config_value(self, path: str) -> Any:
|
|
||||||
"""Get a configuration value based on the provided path.
|
|
||||||
|
|
||||||
Supports string paths (with '/' separators) or sequence paths (list/tuple).
|
|
||||||
Trims leading and trailing '/' from string paths.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Any: The retrieved value.
|
|
||||||
"""
|
|
||||||
return access_nested_value(self, path, False)
|
|
||||||
|
|
||||||
def _create_initial_config_file(self) -> None:
|
def _create_initial_config_file(self) -> None:
|
||||||
if self.general.config_file_path and not self.general.config_file_path.exists():
|
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)
|
self.general.config_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f:
|
with open(self.general.config_file_path, "w") as f:
|
||||||
f.write(self.model_dump_json(indent=4))
|
f.write(self.model_dump_json(indent=4))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -509,7 +437,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
logger.debug(f"Environment config dir: '{env_dir}'")
|
logger.debug(f"Environment config dir: '{env_dir}'")
|
||||||
if env_dir is not None:
|
if env_dir is not None:
|
||||||
config_dirs.append(env_dir.resolve())
|
config_dirs.append(env_dir.resolve())
|
||||||
config_dirs.append(Path(user_config_dir(cls.APP_NAME, cls.APP_AUTHOR)))
|
config_dirs.append(Path(user_config_dir(cls.APP_NAME)))
|
||||||
config_dirs.append(Path.cwd())
|
config_dirs.append(Path.cwd())
|
||||||
for cdir in config_dirs:
|
for cdir in config_dirs:
|
||||||
cfile = cdir.joinpath(cls.CONFIG_FILE_NAME)
|
cfile = cdir.joinpath(cls.CONFIG_FILE_NAME)
|
||||||
@@ -528,7 +456,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
"""
|
"""
|
||||||
if not self.general.config_file_path:
|
if not self.general.config_file_path:
|
||||||
raise ValueError("Configuration file path unknown.")
|
raise ValueError("Configuration file path unknown.")
|
||||||
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out:
|
with self.general.config_file_path.open("w", encoding=self.ENCODING) as f_out:
|
||||||
json_str = super().model_dump_json()
|
json_str = super().model_dump_json()
|
||||||
f_out.write(json_str)
|
f_out.write(json_str)
|
||||||
|
|
||||||
|
@@ -1,12 +1,9 @@
|
|||||||
"""Abstract and base classes for configuration."""
|
"""Abstract and base classes for configuration."""
|
||||||
|
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
|
|
||||||
|
|
||||||
class SettingsBaseModel(PydanticBaseModel):
|
class SettingsBaseModel(PydanticBaseModel):
|
||||||
"""Base model class for all settings configurations."""
|
"""Base model class for all settings configurations."""
|
||||||
|
|
||||||
# EOS configuration - set by ConfigEOS
|
pass
|
||||||
config: ClassVar[Any] = None
|
|
||||||
|
@@ -1,32 +0,0 @@
|
|||||||
"""Settings for caching.
|
|
||||||
|
|
||||||
Kept in an extra module to avoid cyclic dependencies on package import.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class CacheCommonSettings(SettingsBaseModel):
|
|
||||||
"""Cache Configuration."""
|
|
||||||
|
|
||||||
subpath: Optional[Path] = Field(
|
|
||||||
default="cache", description="Sub-path for the EOS cache data directory."
|
|
||||||
)
|
|
||||||
|
|
||||||
cleanup_interval: float = Field(
|
|
||||||
default=5 * 60, description="Intervall in seconds for EOS file cache cleanup."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Do not make this a pydantic computed field. The pydantic model must be fully initialized
|
|
||||||
# to have access to config.general, which may not be the case if it is a computed field.
|
|
||||||
def path(self) -> Optional[Path]:
|
|
||||||
"""Compute cache path based on general.data_folder_path."""
|
|
||||||
data_cache_path = self.config.general.data_folder_path
|
|
||||||
if data_cache_path is None or self.subpath is None:
|
|
||||||
return None
|
|
||||||
return data_cache_path.joinpath(self.subpath)
|
|
@@ -265,12 +265,10 @@ class SingletonMixin:
|
|||||||
class MySingletonModel(SingletonMixin, PydanticBaseModel):
|
class MySingletonModel(SingletonMixin, PydanticBaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
# implement __init__ to avoid re-initialization of parent classes:
|
# implement __init__ to avoid re-initialization of parent class PydanticBaseModel:
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
if hasattr(self, "_initialized"):
|
if hasattr(self, "_initialized"):
|
||||||
return
|
return
|
||||||
# Your initialisation here
|
|
||||||
...
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
instance1 = MySingletonModel(name="Instance 1")
|
instance1 = MySingletonModel(name="Instance 1")
|
||||||
|
@@ -953,44 +953,6 @@ class DataSequence(DataBase, MutableSequence):
|
|||||||
array = resampled.values
|
array = resampled.values
|
||||||
return array
|
return array
|
||||||
|
|
||||||
def to_dataframe(
|
|
||||||
self,
|
|
||||||
start_datetime: Optional[DateTime] = None,
|
|
||||||
end_datetime: Optional[DateTime] = None,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""Converts the sequence of DataRecord instances into a Pandas DataFrame.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_datetime (Optional[datetime]): The lower bound for filtering (inclusive).
|
|
||||||
Defaults to the earliest possible datetime if None.
|
|
||||||
end_datetime (Optional[datetime]): The upper bound for filtering (exclusive).
|
|
||||||
Defaults to the latest possible datetime if None.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
pd.DataFrame: A DataFrame containing the filtered data from all records.
|
|
||||||
"""
|
|
||||||
if not self.records:
|
|
||||||
return pd.DataFrame() # Return empty DataFrame if no records exist
|
|
||||||
|
|
||||||
# Use filter_by_datetime to get filtered records
|
|
||||||
filtered_records = self.filter_by_datetime(start_datetime, end_datetime)
|
|
||||||
|
|
||||||
# Convert filtered records to a dictionary list
|
|
||||||
data = [record.model_dump() for record in filtered_records]
|
|
||||||
|
|
||||||
# Convert to DataFrame
|
|
||||||
df = pd.DataFrame(data)
|
|
||||||
if df.empty:
|
|
||||||
return df
|
|
||||||
|
|
||||||
# Ensure `date_time` column exists and use it for the index
|
|
||||||
if not "date_time" in df.columns:
|
|
||||||
error_msg = f"Cannot create dataframe: no `date_time` column in `{df}`."
|
|
||||||
logger.error(error_msg)
|
|
||||||
raise TypeError(error_msg)
|
|
||||||
df.index = pd.DatetimeIndex(df["date_time"])
|
|
||||||
return df
|
|
||||||
|
|
||||||
def sort_by_datetime(self, reverse: bool = False) -> None:
|
def sort_by_datetime(self, reverse: bool = False) -> None:
|
||||||
"""Sort the DataRecords in the sequence by their date_time attribute.
|
"""Sort the DataRecords in the sequence by their date_time attribute.
|
||||||
|
|
||||||
@@ -1267,14 +1229,14 @@ class DataImportMixin:
|
|||||||
# We jump back by 1 hour
|
# We jump back by 1 hour
|
||||||
# Repeat the value(s) (reuse value index)
|
# Repeat the value(s) (reuse value index)
|
||||||
for i in range(interval_steps_per_hour):
|
for i in range(interval_steps_per_hour):
|
||||||
logger.debug(f"{i + 1}: Repeat at {next_time} with index {value_index}")
|
logger.debug(f"{i+1}: Repeat at {next_time} with index {value_index}")
|
||||||
timestamps_with_indices.append((next_time, value_index))
|
timestamps_with_indices.append((next_time, value_index))
|
||||||
next_time = next_time.add(seconds=interval.total_seconds())
|
next_time = next_time.add(seconds=interval.total_seconds())
|
||||||
else:
|
else:
|
||||||
# We jump forward by 1 hour
|
# We jump forward by 1 hour
|
||||||
# Drop the value(s)
|
# Drop the value(s)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{i + 1}: Skip {interval_steps_per_hour} at {next_time} with index {value_index}"
|
f"{i+1}: Skip {interval_steps_per_hour} at {next_time} with index {value_index}"
|
||||||
)
|
)
|
||||||
value_index += interval_steps_per_hour
|
value_index += interval_steps_per_hour
|
||||||
|
|
||||||
@@ -1503,7 +1465,7 @@ class DataImportMixin:
|
|||||||
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
|
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
|
||||||
logger.debug(f"PydanticDateTimeDataFrame import: {error_msg}")
|
logger.debug(f"PydanticDateTimeDataFrame import: {error_msg}")
|
||||||
|
|
||||||
# Try dictionary with special keys start_datetime and interval
|
# Try dictionary with special keys start_datetime and intervall
|
||||||
try:
|
try:
|
||||||
import_data = PydanticDateTimeData.model_validate_json(json_str)
|
import_data = PydanticDateTimeData.model_validate_json(json_str)
|
||||||
self.import_from_dict(import_data.to_dict())
|
self.import_from_dict(import_data.to_dict())
|
||||||
@@ -1563,7 +1525,7 @@ class DataImportMixin:
|
|||||||
and `key_prefix = "load"`, only the "load_mean" key will be processed even though
|
and `key_prefix = "load"`, only the "load_mean" key will be processed even though
|
||||||
both keys are in the record.
|
both keys are in the record.
|
||||||
"""
|
"""
|
||||||
with import_file_path.open("r", encoding="utf-8", newline=None) as import_file:
|
with import_file_path.open("r") as import_file:
|
||||||
import_str = import_file.read()
|
import_str = import_file.read()
|
||||||
self.import_from_json(
|
self.import_from_json(
|
||||||
import_str, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval
|
import_str, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval
|
||||||
@@ -1845,88 +1807,6 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
|
|||||||
|
|
||||||
return array
|
return array
|
||||||
|
|
||||||
def keys_to_dataframe(
|
|
||||||
self,
|
|
||||||
keys: list[str],
|
|
||||||
start_datetime: Optional[DateTime] = None,
|
|
||||||
end_datetime: Optional[DateTime] = None,
|
|
||||||
interval: Optional[Any] = None, # Duration assumed
|
|
||||||
fill_method: Optional[str] = None,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""Retrieve a dataframe indexed by fixed time intervals for specified keys from the data in each DataProvider.
|
|
||||||
|
|
||||||
Generates a pandas DataFrame using the NumPy arrays for each specified key, ensuring a common time index..
|
|
||||||
|
|
||||||
Args:
|
|
||||||
keys (list[str]): A list of field names to retrieve.
|
|
||||||
start_datetime (datetime, optional): Start date for filtering records (inclusive).
|
|
||||||
end_datetime (datetime, optional): End date for filtering records (exclusive).
|
|
||||||
interval (duration, optional): The fixed time interval. Defaults to 1 hour.
|
|
||||||
fill_method (str, optional): Method to handle missing values during resampling.
|
|
||||||
- 'linear': Linearly interpolate missing values (for numeric data only).
|
|
||||||
- 'ffill': Forward fill missing values.
|
|
||||||
- 'bfill': Backward fill missing values.
|
|
||||||
- 'none': Defaults to 'linear' for numeric values, otherwise 'ffill'.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
pd.DataFrame: A DataFrame where each column represents a key's array with a common time index.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If no valid data is found for any of the requested keys.
|
|
||||||
ValueError: If any retrieved array has a different time index than the first one.
|
|
||||||
"""
|
|
||||||
# Ensure datetime objects are normalized
|
|
||||||
start_datetime = to_datetime(start_datetime, to_maxtime=False) if start_datetime else None
|
|
||||||
end_datetime = to_datetime(end_datetime, to_maxtime=False) if end_datetime else None
|
|
||||||
if interval is None:
|
|
||||||
interval = to_duration("1 hour")
|
|
||||||
if start_datetime is None:
|
|
||||||
# Take earliest datetime of all providers that are enabled
|
|
||||||
for provider in self.enabled_providers:
|
|
||||||
if start_datetime is None:
|
|
||||||
start_datetime = provider.min_datetime
|
|
||||||
elif (
|
|
||||||
provider.min_datetime
|
|
||||||
and compare_datetimes(provider.min_datetime, start_datetime).lt
|
|
||||||
):
|
|
||||||
start_datetime = provider.min_datetime
|
|
||||||
if end_datetime is None:
|
|
||||||
# Take latest datetime of all providers that are enabled
|
|
||||||
for provider in self.enabled_providers:
|
|
||||||
if end_datetime is None:
|
|
||||||
end_datetime = provider.max_datetime
|
|
||||||
elif (
|
|
||||||
provider.max_datetime
|
|
||||||
and compare_datetimes(provider.max_datetime, end_datetime).gt
|
|
||||||
):
|
|
||||||
end_datetime = provider.min_datetime
|
|
||||||
if end_datetime:
|
|
||||||
end_datetime.add(seconds=1)
|
|
||||||
|
|
||||||
# Create a DatetimeIndex based on start, end, and interval
|
|
||||||
reference_index = pd.date_range(
|
|
||||||
start=start_datetime, end=end_datetime, freq=interval, inclusive="left"
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {}
|
|
||||||
for key in keys:
|
|
||||||
try:
|
|
||||||
array = self.key_to_array(key, start_datetime, end_datetime, interval, fill_method)
|
|
||||||
|
|
||||||
if len(array) != len(reference_index):
|
|
||||||
raise ValueError(
|
|
||||||
f"Array length mismatch for key '{key}' (expected {len(reference_index)}, got {len(array)})"
|
|
||||||
)
|
|
||||||
|
|
||||||
data[key] = array
|
|
||||||
except KeyError as e:
|
|
||||||
raise KeyError(f"Failed to retrieve data for key '{key}': {e}")
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
raise KeyError(f"No valid data found for the requested keys {keys}.")
|
|
||||||
|
|
||||||
return pd.DataFrame(data, index=reference_index)
|
|
||||||
|
|
||||||
def provider_by_id(self, provider_id: str) -> DataProvider:
|
def provider_by_id(self, provider_id: str) -> DataProvider:
|
||||||
"""Retrieves a data provider by its unique identifier.
|
"""Retrieves a data provider by its unique identifier.
|
||||||
|
|
||||||
|
@@ -19,6 +19,7 @@ class classproperty:
|
|||||||
class MyClass:
|
class MyClass:
|
||||||
_value = 42
|
_value = 42
|
||||||
|
|
||||||
|
@classmethod
|
||||||
@classproperty
|
@classproperty
|
||||||
def value(cls):
|
def value(cls):
|
||||||
return cls._value
|
return cls._value
|
||||||
|
@@ -6,20 +6,19 @@ from pendulum import DateTime
|
|||||||
from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator
|
from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import CacheUntilUpdateStore
|
|
||||||
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin
|
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import ParametersBaseModel, PydanticBaseModel
|
from akkudoktoreos.core.pydantic import ParametersBaseModel, PydanticBaseModel
|
||||||
from akkudoktoreos.devices.battery import Battery
|
from akkudoktoreos.devices.battery import Battery
|
||||||
from akkudoktoreos.devices.generic import HomeAppliance
|
from akkudoktoreos.devices.generic import HomeAppliance
|
||||||
from akkudoktoreos.devices.inverter import Inverter
|
from akkudoktoreos.devices.inverter import Inverter
|
||||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class EnergyManagementParameters(ParametersBaseModel):
|
class EnergieManagementSystemParameters(ParametersBaseModel):
|
||||||
pv_prognose_wh: list[float] = Field(
|
pv_prognose_wh: list[float] = Field(
|
||||||
description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
|
description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
|
||||||
)
|
)
|
||||||
@@ -108,7 +107,7 @@ class SimulationResult(ParametersBaseModel):
|
|||||||
return NumpyEncoder.convert_numpy(field)[0]
|
return NumpyEncoder.convert_numpy(field)[0]
|
||||||
|
|
||||||
|
|
||||||
class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel):
|
class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel):
|
||||||
# Disable validation on assignment to speed up simulation runs.
|
# Disable validation on assignment to speed up simulation runs.
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
validate_assignment=False,
|
validate_assignment=False,
|
||||||
@@ -117,33 +116,16 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
|||||||
# Start datetime.
|
# Start datetime.
|
||||||
_start_datetime: ClassVar[Optional[DateTime]] = None
|
_start_datetime: ClassVar[Optional[DateTime]] = None
|
||||||
|
|
||||||
# last run datetime. Used by energy management task
|
|
||||||
_last_datetime: ClassVar[Optional[DateTime]] = None
|
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
def start_datetime(self) -> DateTime:
|
def start_datetime(self) -> DateTime:
|
||||||
"""The starting datetime of the current or latest energy management."""
|
"""The starting datetime of the current or latest energy management."""
|
||||||
if EnergyManagement._start_datetime is None:
|
if EnergieManagementSystem._start_datetime is None:
|
||||||
EnergyManagement.set_start_datetime()
|
EnergieManagementSystem.set_start_datetime()
|
||||||
return EnergyManagement._start_datetime
|
return EnergieManagementSystem._start_datetime
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime:
|
def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime:
|
||||||
"""Set the start datetime for the next energy management cycle.
|
|
||||||
|
|
||||||
If no datetime is provided, the current datetime is used.
|
|
||||||
|
|
||||||
The start datetime is always rounded down to the nearest hour
|
|
||||||
(i.e., setting minutes, seconds, and microseconds to zero).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_datetime (Optional[DateTime]): The datetime to set as the start.
|
|
||||||
If None, the current datetime is used.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DateTime: The adjusted start datetime.
|
|
||||||
"""
|
|
||||||
if start_datetime is None:
|
if start_datetime is None:
|
||||||
start_datetime = to_datetime()
|
start_datetime = to_datetime()
|
||||||
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
|
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
|
||||||
@@ -194,7 +176,7 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
|||||||
|
|
||||||
def set_parameters(
|
def set_parameters(
|
||||||
self,
|
self,
|
||||||
parameters: EnergyManagementParameters,
|
parameters: EnergieManagementSystemParameters,
|
||||||
ev: Optional[Battery] = None,
|
ev: Optional[Battery] = None,
|
||||||
home_appliance: Optional[HomeAppliance] = None,
|
home_appliance: Optional[HomeAppliance] = None,
|
||||||
inverter: Optional[Inverter] = None,
|
inverter: Optional[Inverter] = None,
|
||||||
@@ -261,9 +243,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
|||||||
is mostly relevant to prediction providers.
|
is mostly relevant to prediction providers.
|
||||||
force_update (bool, optional): If True, forces to update the data even if still cached.
|
force_update (bool, optional): If True, forces to update the data even if still cached.
|
||||||
"""
|
"""
|
||||||
# Throw away any cached results of the last run.
|
|
||||||
CacheUntilUpdateStore().clear()
|
|
||||||
self.set_start_hour(start_hour=start_hour)
|
self.set_start_hour(start_hour=start_hour)
|
||||||
|
self.config.update()
|
||||||
|
|
||||||
# Check for run definitions
|
# Check for run definitions
|
||||||
if self.start_datetime is None:
|
if self.start_datetime is None:
|
||||||
@@ -274,70 +255,14 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
|||||||
error_msg = "Prediction hours unknown."
|
error_msg = "Prediction hours unknown."
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
if self.config.optimization.hours is None:
|
if self.config.prediction.optimisation_hours is None:
|
||||||
error_msg = "Optimization hours unknown."
|
error_msg = "Optimisation hours unknown."
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
self.prediction.update_data(force_enable=force_enable, force_update=force_update)
|
self.prediction.update_data(force_enable=force_enable, force_update=force_update)
|
||||||
# TODO: Create optimisation problem that calls into devices.update_data() for simulations.
|
# TODO: Create optimisation problem that calls into devices.update_data() for simulations.
|
||||||
|
|
||||||
def manage_energy(self) -> None:
|
|
||||||
"""Repeating task for managing energy.
|
|
||||||
|
|
||||||
This task should be executed by the server regularly (e.g., every 10 seconds)
|
|
||||||
to ensure proper energy management. Configuration changes to the energy management interval
|
|
||||||
will only take effect if this task is executed.
|
|
||||||
|
|
||||||
- Initializes and runs the energy management for the first time if it has never been run
|
|
||||||
before.
|
|
||||||
- If the energy management interval is not configured or invalid (NaN), the task will not
|
|
||||||
trigger any repeated energy management runs.
|
|
||||||
- Compares the current time with the last run time and runs the energy management if the
|
|
||||||
interval has elapsed.
|
|
||||||
- Logs any exceptions that occur during the initialization or execution of the energy
|
|
||||||
management.
|
|
||||||
|
|
||||||
Note: The task maintains the interval even if some intervals are missed.
|
|
||||||
"""
|
|
||||||
current_datetime = to_datetime()
|
|
||||||
|
|
||||||
if EnergyManagement._last_datetime is None:
|
|
||||||
# Never run before
|
|
||||||
try:
|
|
||||||
# Try to run a first energy management. May fail due to config incomplete.
|
|
||||||
self.run()
|
|
||||||
# Remember energy run datetime.
|
|
||||||
EnergyManagement._last_datetime = current_datetime
|
|
||||||
except Exception as e:
|
|
||||||
message = f"EOS init: {e}"
|
|
||||||
logger.error(message)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.config.ems.interval is None or self.config.ems.interval == float("nan"):
|
|
||||||
# No Repetition
|
|
||||||
return
|
|
||||||
|
|
||||||
if (
|
|
||||||
compare_datetimes(current_datetime, self._last_datetime).time_diff
|
|
||||||
< self.config.ems.interval
|
|
||||||
):
|
|
||||||
# Wait for next run
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.run()
|
|
||||||
except Exception as e:
|
|
||||||
message = f"EOS run: {e}"
|
|
||||||
logger.error(message)
|
|
||||||
|
|
||||||
# Remember the energy management run - keep on interval even if we missed some intervals
|
|
||||||
while (
|
|
||||||
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
|
|
||||||
>= self.config.ems.interval
|
|
||||||
):
|
|
||||||
EnergyManagement._last_datetime.add(seconds=self.config.ems.interval)
|
|
||||||
|
|
||||||
def set_start_hour(self, start_hour: Optional[int] = None) -> None:
|
def set_start_hour(self, start_hour: Optional[int] = None) -> None:
|
||||||
"""Sets start datetime to given hour.
|
"""Sets start datetime to given hour.
|
||||||
|
|
||||||
@@ -515,9 +440,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
|||||||
|
|
||||||
|
|
||||||
# Initialize the Energy Management System, it is a singleton.
|
# Initialize the Energy Management System, it is a singleton.
|
||||||
ems = EnergyManagement()
|
ems = EnergieManagementSystem()
|
||||||
|
|
||||||
|
|
||||||
def get_ems() -> EnergyManagement:
|
def get_ems() -> EnergieManagementSystem:
|
||||||
"""Gets the EOS Energy Management System."""
|
"""Gets the EOS Energy Management System."""
|
||||||
return ems
|
return ems
|
||||||
|
@@ -1,26 +0,0 @@
|
|||||||
"""Settings for energy management.
|
|
||||||
|
|
||||||
Kept in an extra module to avoid cyclic dependencies on package import.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class EnergyManagementCommonSettings(SettingsBaseModel):
|
|
||||||
"""Energy Management Configuration."""
|
|
||||||
|
|
||||||
startup_delay: float = Field(
|
|
||||||
default=5,
|
|
||||||
ge=1,
|
|
||||||
description="Startup delay in seconds for EOS energy management runs.",
|
|
||||||
)
|
|
||||||
|
|
||||||
interval: Optional[float] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Intervall in seconds between EOS energy management runs.",
|
|
||||||
examples=["300"],
|
|
||||||
)
|
|
@@ -52,10 +52,6 @@ def get_logger(
|
|||||||
# Create a logger with the specified name
|
# Create a logger with the specified name
|
||||||
logger = pylogging.getLogger(name)
|
logger = pylogging.getLogger(name)
|
||||||
logger.propagate = True
|
logger.propagate = True
|
||||||
# This is already supported by pydantic-settings in LoggingCommonSettings, however in case
|
|
||||||
# loading the config itself fails and to set the level before we load the config, we set it here manually.
|
|
||||||
if logging_level is None and (env_level := os.getenv("EOS_LOGGING__LEVEL")) is not None:
|
|
||||||
logging_level = env_level
|
|
||||||
if logging_level is not None:
|
if logging_level is not None:
|
||||||
level = logging_str_to_level(logging_level)
|
level = logging_str_to_level(logging_level)
|
||||||
logger.setLevel(level)
|
logger.setLevel(level)
|
||||||
|
@@ -51,70 +51,6 @@ def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, An
|
|||||||
return merged_dict
|
return merged_dict
|
||||||
|
|
||||||
|
|
||||||
def access_nested_value(
|
|
||||||
model: BaseModel, path: str, setter: bool, value: Optional[Any] = None
|
|
||||||
) -> Any:
|
|
||||||
"""Get or set a nested model value based on the provided path.
|
|
||||||
|
|
||||||
Supports string paths (with '/' separators) or sequence paths (list/tuple).
|
|
||||||
Trims leading and trailing '/' from string paths.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model (BaseModel): The model object for partial assignment.
|
|
||||||
path (str): The path to the model key (e.g., "key1/key2/key3" or key1/key2/0).
|
|
||||||
setter (bool): True to set value at path, False to return value at path.
|
|
||||||
value (Optional[Any]): The value to set.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Any: The retrieved value if acting as a getter, or None if setting a value.
|
|
||||||
"""
|
|
||||||
path_elements = path.strip("/").split("/")
|
|
||||||
|
|
||||||
cfg: Any = model
|
|
||||||
parent: BaseModel = model
|
|
||||||
model_key: str = ""
|
|
||||||
|
|
||||||
for i, key in enumerate(path_elements):
|
|
||||||
is_final_key = i == len(path_elements) - 1
|
|
||||||
|
|
||||||
if isinstance(cfg, list):
|
|
||||||
try:
|
|
||||||
idx = int(key)
|
|
||||||
if is_final_key:
|
|
||||||
if not setter: # Getter
|
|
||||||
return cfg[idx]
|
|
||||||
else: # Setter
|
|
||||||
new_list = list(cfg)
|
|
||||||
new_list[idx] = value
|
|
||||||
# Trigger validation
|
|
||||||
setattr(parent, model_key, new_list)
|
|
||||||
else:
|
|
||||||
cfg = cfg[idx]
|
|
||||||
except ValidationError as e:
|
|
||||||
raise ValueError(f"Error updating model: {e}") from e
|
|
||||||
except (ValueError, IndexError) as e:
|
|
||||||
raise IndexError(f"Invalid list index at {path}: {key}") from e
|
|
||||||
|
|
||||||
elif isinstance(cfg, BaseModel):
|
|
||||||
parent = cfg
|
|
||||||
model_key = key
|
|
||||||
if is_final_key:
|
|
||||||
if not setter: # Getter
|
|
||||||
return getattr(cfg, key)
|
|
||||||
else: # Setter
|
|
||||||
try:
|
|
||||||
# Verification also if nested value is provided opposed to just setattr
|
|
||||||
# Will merge partial assignment
|
|
||||||
cfg = cfg.__pydantic_validator__.validate_assignment(cfg, key, value)
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Error updating model: {e}") from e
|
|
||||||
else:
|
|
||||||
cfg = getattr(cfg, key)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise KeyError(f"Key '{key}' not found in model.")
|
|
||||||
|
|
||||||
|
|
||||||
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
|
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
|
||||||
"""Custom type adapter for Pendulum DateTime fields."""
|
"""Custom type adapter for Pendulum DateTime fields."""
|
||||||
|
|
||||||
@@ -437,10 +373,6 @@ class PydanticDateTimeDataFrame(PydanticBaseModel):
|
|||||||
index = pd.Index([to_datetime(dt, in_timezone=self.tz) for dt in df.index])
|
index = pd.Index([to_datetime(dt, in_timezone=self.tz) for dt in df.index])
|
||||||
df.index = index
|
df.index = index
|
||||||
|
|
||||||
# Check if 'date_time' column exists, if not, create it
|
|
||||||
if "date_time" not in df.columns:
|
|
||||||
df["date_time"] = df.index
|
|
||||||
|
|
||||||
dtype_mapping = {
|
dtype_mapping = {
|
||||||
"int": int,
|
"int": int,
|
||||||
"float": float,
|
"float": float,
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
|
import logging
|
||||||
from typing import List, Sequence
|
from typing import List, Sequence
|
||||||
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
|
||||||
|
|
||||||
|
|
||||||
class Heatpump:
|
class Heatpump:
|
||||||
MAX_HEAT_OUTPUT = 5000
|
MAX_HEAT_OUTPUT = 5000
|
||||||
@@ -22,7 +21,7 @@ class Heatpump:
|
|||||||
def __init__(self, max_heat_output: int, hours: int):
|
def __init__(self, max_heat_output: int, hours: int):
|
||||||
self.max_heat_output = max_heat_output
|
self.max_heat_output = max_heat_output
|
||||||
self.hours = hours
|
self.hours = hours
|
||||||
self.log = get_logger(__name__)
|
self.log = logging.getLogger(__name__)
|
||||||
|
|
||||||
def __check_outside_temperature_range__(self, temp_celsius: float) -> bool:
|
def __check_outside_temperature_range__(self, temp_celsius: float) -> bool:
|
||||||
"""Check if temperature is in valid range between -100 and 100 degree Celsius.
|
"""Check if temperature is in valid range between -100 and 100 degree Celsius.
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -12,7 +13,7 @@ from akkudoktoreos.core.coreabc import (
|
|||||||
DevicesMixin,
|
DevicesMixin,
|
||||||
EnergyManagementSystemMixin,
|
EnergyManagementSystemMixin,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.core.ems import EnergyManagementParameters, SimulationResult
|
from akkudoktoreos.core.ems import EnergieManagementSystemParameters, SimulationResult
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||||
from akkudoktoreos.devices.battery import (
|
from akkudoktoreos.devices.battery import (
|
||||||
@@ -29,7 +30,7 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class OptimizationParameters(ParametersBaseModel):
|
class OptimizationParameters(ParametersBaseModel):
|
||||||
ems: EnergyManagementParameters
|
ems: EnergieManagementSystemParameters
|
||||||
pv_akku: Optional[SolarPanelBatteryParameters]
|
pv_akku: Optional[SolarPanelBatteryParameters]
|
||||||
inverter: Optional[InverterParameters]
|
inverter: Optional[InverterParameters]
|
||||||
eauto: Optional[ElectricVehicleParameters]
|
eauto: Optional[ElectricVehicleParameters]
|
||||||
@@ -120,7 +121,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
|||||||
# Set a fixed seed for random operations if provided or in debug mode
|
# Set a fixed seed for random operations if provided or in debug mode
|
||||||
if self.fix_seed is not None:
|
if self.fix_seed is not None:
|
||||||
random.seed(self.fix_seed)
|
random.seed(self.fix_seed)
|
||||||
elif logger.level == "DEBUG":
|
elif logger.level == logging.DEBUG:
|
||||||
self.fix_seed = random.randint(1, 100000000000)
|
self.fix_seed = random.randint(1, 100000000000)
|
||||||
random.seed(self.fix_seed)
|
random.seed(self.fix_seed)
|
||||||
|
|
||||||
|
@@ -14,10 +14,10 @@ import requests
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import cache_in_file
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||||
|
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
@@ -63,9 +63,6 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
|
|||||||
return "ElecPriceImport"
|
return "ElecPriceImport"
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
if self.config.elecprice.provider_settings is None:
|
|
||||||
logger.debug(f"{self.provider_id()} data update without provider settings.")
|
|
||||||
return
|
|
||||||
if self.config.elecprice.provider_settings.import_file_path:
|
if self.config.elecprice.provider_settings.import_file_path:
|
||||||
self.import_from_file(
|
self.import_from_file(
|
||||||
self.config.elecprice.provider_settings.import_file_path,
|
self.config.elecprice.provider_settings.import_file_path,
|
||||||
|
@@ -62,9 +62,6 @@ class LoadImport(LoadProvider, PredictionImportProvider):
|
|||||||
return "LoadImport"
|
return "LoadImport"
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
if self.config.load.provider_settings is None:
|
|
||||||
logger.debug(f"{self.provider_id()} data update without provider settings.")
|
|
||||||
return
|
|
||||||
if self.config.load.provider_settings.import_file_path:
|
if self.config.load.provider_settings.import_file_path:
|
||||||
self.import_from_file(self.config.provider_settings.import_file_path, key_prefix="load")
|
self.import_from_file(self.config.provider_settings.import_file_path, key_prefix="load")
|
||||||
if self.config.load.provider_settings.import_json:
|
if self.config.load.provider_settings.import_json:
|
||||||
|
@@ -206,6 +206,9 @@ class PredictionProvider(PredictionStartEndKeepMixin, DataProvider):
|
|||||||
force_enable (bool, optional): If True, forces the update even if the provider is disabled.
|
force_enable (bool, optional): If True, forces the update even if the provider is disabled.
|
||||||
force_update (bool, optional): If True, forces the provider to update the data even if still cached.
|
force_update (bool, optional): If True, forces the provider to update the data even if still cached.
|
||||||
"""
|
"""
|
||||||
|
# Update prediction configuration
|
||||||
|
self.config.update()
|
||||||
|
|
||||||
# Check after configuration is updated.
|
# Check after configuration is updated.
|
||||||
if not force_enable and not self.enabled():
|
if not force_enable and not self.enabled():
|
||||||
return
|
return
|
||||||
|
@@ -80,13 +80,13 @@ from typing import Any, List, Optional, Union
|
|||||||
import requests
|
import requests
|
||||||
from pydantic import Field, ValidationError, computed_field
|
from pydantic import Field, ValidationError, computed_field
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import cache_in_file
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
from akkudoktoreos.prediction.pvforecastabc import (
|
from akkudoktoreos.prediction.pvforecastabc import (
|
||||||
PVForecastDataRecord,
|
PVForecastDataRecord,
|
||||||
PVForecastProvider,
|
PVForecastProvider,
|
||||||
)
|
)
|
||||||
|
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -267,7 +267,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
|||||||
logger.debug(f"Response from {self._url()}: {response}")
|
logger.debug(f"Response from {self._url()}: {response}")
|
||||||
akkudoktor_data = self._validate_data(response.content)
|
akkudoktor_data = self._validate_data(response.content)
|
||||||
# We are working on fresh data (no cache), report update time
|
# We are working on fresh data (no cache), report update time
|
||||||
|
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||||
return akkudoktor_data
|
return akkudoktor_data
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
|
@@ -63,9 +63,6 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
|
|||||||
return "PVForecastImport"
|
return "PVForecastImport"
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
if self.config.pvforecast.provider_settings is None:
|
|
||||||
logger.debug(f"{self.provider_id()} data update without provider settings.")
|
|
||||||
return
|
|
||||||
if self.config.pvforecast.provider_settings.import_file_path is not None:
|
if self.config.pvforecast.provider_settings.import_file_path is not None:
|
||||||
self.import_from_file(
|
self.import_from_file(
|
||||||
self.config.pvforecast.provider_settings.import_file_path,
|
self.config.pvforecast.provider_settings.import_file_path,
|
||||||
|
@@ -13,9 +13,9 @@ import pandas as pd
|
|||||||
import pvlib
|
import pvlib
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import cache_in_file
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
|
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
|
||||||
|
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
@@ -19,9 +19,9 @@ import pandas as pd
|
|||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import cache_in_file
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
|
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
|
||||||
|
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_timezone
|
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_timezone
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
@@ -63,9 +63,6 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
|
|||||||
return "WeatherImport"
|
return "WeatherImport"
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
if self.config.weather.provider_settings is None:
|
|
||||||
logger.debug(f"{self.provider_id()} data update without provider settings.")
|
|
||||||
return
|
|
||||||
if self.config.weather.provider_settings.import_file_path:
|
if self.config.weather.provider_settings.import_file_path:
|
||||||
self.import_from_file(
|
self.import_from_file(
|
||||||
self.config.weather.provider_settings.import_file_path, key_prefix="weather"
|
self.config.weather.provider_settings.import_file_path, key_prefix="weather"
|
||||||
|
Before Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 112 KiB |
Before Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 724 B |
Before Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
|||||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
|
Before Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 12 KiB |
@@ -1,38 +0,0 @@
|
|||||||
# Module taken from https://github.com/koaning/fh-altair
|
|
||||||
# MIT license
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bokeh.embed import components
|
|
||||||
from bokeh.models import Plot
|
|
||||||
from monsterui.franken import H4, Card, NotStr, Script
|
|
||||||
|
|
||||||
BokehJS = [
|
|
||||||
Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.6.3.min.js", crossorigin="anonymous"),
|
|
||||||
Script(
|
|
||||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.6.3.min.js",
|
|
||||||
crossorigin="anonymous",
|
|
||||||
),
|
|
||||||
Script(
|
|
||||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.6.3.min.js", crossorigin="anonymous"
|
|
||||||
),
|
|
||||||
Script(
|
|
||||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.6.3.min.js", crossorigin="anonymous"
|
|
||||||
),
|
|
||||||
Script(
|
|
||||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.6.3.min.js",
|
|
||||||
crossorigin="anonymous",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def Bokeh(plot: Plot, header: Optional[str] = None) -> Card:
|
|
||||||
"""Converts an Bokeh plot to a FastHTML FT component."""
|
|
||||||
script, div = components(plot)
|
|
||||||
if header:
|
|
||||||
header = H4(header, cls="mt-2")
|
|
||||||
return Card(
|
|
||||||
NotStr(div),
|
|
||||||
NotStr(script),
|
|
||||||
header=header,
|
|
||||||
)
|
|
@@ -1,224 +0,0 @@
|
|||||||
from typing import Any, Optional, Union
|
|
||||||
|
|
||||||
from fasthtml.common import H1, Div, Li
|
|
||||||
|
|
||||||
# from mdit_py_plugins import plugin1, plugin2
|
|
||||||
from monsterui.foundations import stringify
|
|
||||||
from monsterui.franken import (
|
|
||||||
Button,
|
|
||||||
ButtonT,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
ContainerT,
|
|
||||||
Details,
|
|
||||||
DivLAligned,
|
|
||||||
DivRAligned,
|
|
||||||
Grid,
|
|
||||||
Input,
|
|
||||||
P,
|
|
||||||
Summary,
|
|
||||||
TabContainer,
|
|
||||||
UkIcon,
|
|
||||||
)
|
|
||||||
|
|
||||||
scrollbar_viewport_styles = (
|
|
||||||
"scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;"
|
|
||||||
)
|
|
||||||
|
|
||||||
scrollbar_cls = "flex touch-none select-none transition-colors p-[1px]"
|
|
||||||
|
|
||||||
|
|
||||||
def ScrollArea(
|
|
||||||
*c: Any, cls: Optional[Union[str, tuple]] = None, orientation: str = "vertical", **kwargs: Any
|
|
||||||
) -> Div:
|
|
||||||
"""Creates a styled scroll area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
orientation (str): The orientation of the scroll area. Defaults to vertical.
|
|
||||||
"""
|
|
||||||
new_cls = "relative overflow-hidden"
|
|
||||||
if cls:
|
|
||||||
new_cls += f" {stringify(cls)}"
|
|
||||||
kwargs["cls"] = new_cls
|
|
||||||
|
|
||||||
content = Div(
|
|
||||||
Div(*c, style="min-width:100%;display:table;"),
|
|
||||||
style=f"overflow: {'hidden scroll' if orientation == 'vertical' else 'scroll'}; {scrollbar_viewport_styles}",
|
|
||||||
cls="w-full h-full rounded-[inherit]",
|
|
||||||
data_ref="viewport",
|
|
||||||
)
|
|
||||||
|
|
||||||
scrollbar = Div(
|
|
||||||
Div(cls="bg-border rounded-full hidden relative flex-1", data_ref="thumb"),
|
|
||||||
cls=f"{scrollbar_cls} flex-col h-2.5 w-full border-t border-t-transparent"
|
|
||||||
if orientation == "horizontal"
|
|
||||||
else f"{scrollbar_cls} w-2.5 h-full border-l border-l-transparent",
|
|
||||||
data_ref="scrollbar",
|
|
||||||
style=f"position: absolute;{'right:0; top:0;' if orientation == 'vertical' else 'bottom:0; left:0;'}",
|
|
||||||
)
|
|
||||||
|
|
||||||
return Div(
|
|
||||||
content,
|
|
||||||
scrollbar,
|
|
||||||
role="region",
|
|
||||||
tabindex="0",
|
|
||||||
data_orientation=orientation,
|
|
||||||
data_ref_scrollarea=True,
|
|
||||||
aria_label="Scrollable content",
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ConfigCard(
|
|
||||||
config_name: str, config_type: str, read_only: str, value: str, default: str, description: str
|
|
||||||
) -> Card:
|
|
||||||
return Card(
|
|
||||||
Details(
|
|
||||||
Summary(
|
|
||||||
Grid(
|
|
||||||
Grid(
|
|
||||||
DivLAligned(
|
|
||||||
UkIcon(icon="play"),
|
|
||||||
P(config_name),
|
|
||||||
),
|
|
||||||
DivRAligned(
|
|
||||||
P(read_only),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Input(value=value) if read_only == "rw" else P(value),
|
|
||||||
),
|
|
||||||
# cls="flex cursor-pointer list-none items-center gap-4",
|
|
||||||
cls="list-none",
|
|
||||||
),
|
|
||||||
Grid(
|
|
||||||
P(description),
|
|
||||||
P(config_type),
|
|
||||||
),
|
|
||||||
Grid(
|
|
||||||
DivRAligned(
|
|
||||||
P("default") if read_only == "rw" else P(""),
|
|
||||||
),
|
|
||||||
P(default) if read_only == "rw" else P(""),
|
|
||||||
)
|
|
||||||
if read_only == "rw"
|
|
||||||
else None,
|
|
||||||
cls="space-y-4 gap-4",
|
|
||||||
),
|
|
||||||
cls="w-full",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def DashboardHeader(title: Optional[str]) -> Div:
|
|
||||||
"""Creates a styled header with a title.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title (Optional[str]): The title text for the header.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Div: A styled `Div` element containing the header.
|
|
||||||
"""
|
|
||||||
if title is None:
|
|
||||||
return Div("", cls="header")
|
|
||||||
return Div(H1(title, cls="text-2xl font-bold mb-4"), cls="header")
|
|
||||||
|
|
||||||
|
|
||||||
def DashboardFooter(*c: Any, path: str) -> Card:
|
|
||||||
"""Creates a styled footer with the provided information.
|
|
||||||
|
|
||||||
The footer content is reloaded every 5 seconds from path.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (str): Path to reload footer content from
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Card: A styled `Card` element containing the footer.
|
|
||||||
"""
|
|
||||||
return Card(
|
|
||||||
Container(*c, id="footer-content"),
|
|
||||||
hx_get=f"{path}",
|
|
||||||
hx_trigger="every 5s",
|
|
||||||
hx_target="#footer-content",
|
|
||||||
hx_swap="innerHTML",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def DashboardTrigger(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
|
|
||||||
"""Creates a styled button for the dashboard trigger.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
*c: Positional arguments to pass to the button.
|
|
||||||
cls (Optional[str]): Additional CSS classes for styling. Defaults to None.
|
|
||||||
**kwargs: Additional keyword arguments for the button.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Button: A styled `Button` component.
|
|
||||||
"""
|
|
||||||
new_cls = f"{ButtonT.primary}"
|
|
||||||
if cls:
|
|
||||||
new_cls += f" {stringify(cls)}"
|
|
||||||
kwargs["cls"] = new_cls
|
|
||||||
return Button(*c, submit=False, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def DashboardTabs(dashboard_items: dict[str, str]) -> Card:
|
|
||||||
"""Creates a dashboard tab with dynamic dashboard items.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dashboard_items (dict[str, str]): A dictionary of dashboard items where keys are item names
|
|
||||||
and values are paths for navigation.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Card: A styled `Card` component containing the dashboard tabs.
|
|
||||||
"""
|
|
||||||
dash_items = [
|
|
||||||
Li(
|
|
||||||
DashboardTrigger(
|
|
||||||
menu,
|
|
||||||
hx_get=f"{path}",
|
|
||||||
hx_target="#page-content",
|
|
||||||
hx_swap="innerHTML",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for menu, path in dashboard_items.items()
|
|
||||||
]
|
|
||||||
return Card(TabContainer(*dash_items, cls="gap-4"), alt=True)
|
|
||||||
|
|
||||||
|
|
||||||
def DashboardContent(content: Any) -> Card:
|
|
||||||
"""Creates a content section within a styled card.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content (Any): The content to display.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Card: A styled `Card` element containing the content.
|
|
||||||
"""
|
|
||||||
return Card(ScrollArea(Container(content, id="page-content"), cls="h-[75vh] w-full rounded-md"))
|
|
||||||
|
|
||||||
|
|
||||||
def Page(
|
|
||||||
title: Optional[str],
|
|
||||||
dashboard_items: dict[str, str],
|
|
||||||
content: Any,
|
|
||||||
footer_content: Any,
|
|
||||||
footer_path: str,
|
|
||||||
) -> Div:
|
|
||||||
"""Generates a full-page layout with a header, dashboard items, content, and footer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title (Optional[str]): The page title.
|
|
||||||
dashboard_items (dict[str, str]): A dictionary of dashboard items.
|
|
||||||
content (Any): The main content for the page.
|
|
||||||
footer_content (Any): Footer content.
|
|
||||||
footer_path (Any): Path to reload footer content from.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Div: A `Div` element representing the entire page layout.
|
|
||||||
"""
|
|
||||||
return Container(
|
|
||||||
DashboardHeader(title),
|
|
||||||
DashboardTabs(dashboard_items),
|
|
||||||
DashboardContent(content),
|
|
||||||
DashboardFooter(footer_content, path=footer_path),
|
|
||||||
cls=("bg-background text-foreground w-screen p-4 space-y-4", ContainerT.xl),
|
|
||||||
)
|
|
@@ -1,275 +0,0 @@
|
|||||||
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from monsterui.franken import Div, DividerLine, P, Table, Tbody, Td, Th, Thead, Tr
|
|
||||||
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
|
|
||||||
from akkudoktoreos.server.dash.components import ConfigCard
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
config_eos = get_config()
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
def get_nested_value(
|
|
||||||
dictionary: Union[Dict[str, Any], List[Any]],
|
|
||||||
keys: Sequence[Union[str, int]],
|
|
||||||
default: Optional[T] = None,
|
|
||||||
) -> Union[Any, T]:
|
|
||||||
"""Retrieve a nested value from a dictionary or list using a sequence of keys.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dictionary (Union[Dict[str, Any], List[Any]]): The nested dictionary or list to search.
|
|
||||||
keys (Sequence[Union[str, int]]): A sequence of keys or indices representing the path to the desired value.
|
|
||||||
default (Optional[T]): A value to return if the path is not found.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Union[Any, T]: The value at the specified nested path, or the default value if not found.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If the input is not a dictionary or list, or if keys are not a sequence.
|
|
||||||
KeyError: If a key is not found in a dictionary.
|
|
||||||
IndexError: If an index is out of range in a list.
|
|
||||||
"""
|
|
||||||
if not isinstance(dictionary, (dict, list)):
|
|
||||||
raise TypeError("The first argument must be a dictionary or list")
|
|
||||||
if not isinstance(keys, Sequence):
|
|
||||||
raise TypeError("Keys must be provided as a sequence (e.g., list, tuple)")
|
|
||||||
|
|
||||||
if not keys:
|
|
||||||
return dictionary
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Traverse the structure
|
|
||||||
current = dictionary
|
|
||||||
for key in keys:
|
|
||||||
if isinstance(current, dict) and isinstance(key, str):
|
|
||||||
current = current[key]
|
|
||||||
elif isinstance(current, list) and isinstance(key, int):
|
|
||||||
current = current[key]
|
|
||||||
else:
|
|
||||||
raise KeyError(f"Invalid key or index: {key}")
|
|
||||||
return current
|
|
||||||
except (KeyError, IndexError, TypeError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
|
|
||||||
"""Retrieve the default value of a field.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
field_info (Union[FieldInfo, ComputedFieldInfo]): The field metadata from Pydantic.
|
|
||||||
regular_field (bool): Indicates if the field is a regular field.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Any: The default value of the field or "N/A" if not a regular field.
|
|
||||||
"""
|
|
||||||
default_value = ""
|
|
||||||
if regular_field:
|
|
||||||
if (val := field_info.default) is not PydanticUndefined:
|
|
||||||
default_value = val
|
|
||||||
else:
|
|
||||||
default_value = "N/A"
|
|
||||||
return default_value
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
|
|
||||||
"""Resolve nested types within a field and return their structure.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
field_type (Any): The type of the field to resolve.
|
|
||||||
parent_types (List[str]): A list of parent type names.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[tuple[Any, List[str]]]: A list of tuples containing resolved types and their parent hierarchy.
|
|
||||||
"""
|
|
||||||
resolved_types: list[tuple[Any, list[str]]] = []
|
|
||||||
|
|
||||||
origin = getattr(field_type, "__origin__", field_type)
|
|
||||||
if origin is Union:
|
|
||||||
for arg in getattr(field_type, "__args__", []):
|
|
||||||
if arg is not type(None):
|
|
||||||
resolved_types.extend(resolve_nested_types(arg, parent_types))
|
|
||||||
else:
|
|
||||||
resolved_types.append((field_type, parent_types))
|
|
||||||
|
|
||||||
return resolved_types
|
|
||||||
|
|
||||||
|
|
||||||
def configuration(values: dict) -> list[dict]:
|
|
||||||
"""Generate configuration details based on provided values and model metadata.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
values (dict): A dictionary containing the current configuration values.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[dict]: A sorted list of configuration details, each represented as a dictionary.
|
|
||||||
"""
|
|
||||||
configs = []
|
|
||||||
inner_types: set[type[PydanticBaseModel]] = set()
|
|
||||||
|
|
||||||
for field_name, field_info in list(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)
|
|
||||||
config["value"] = str(get_nested_value(values, parent_types, "<unknown>"))
|
|
||||||
config["default"] = str(get_default_value(subfield_info, regular_field))
|
|
||||||
config["description"] = (
|
|
||||||
subfield_info.description if subfield_info.description else ""
|
|
||||||
)
|
|
||||||
if isinstance(subfield_info, ComputedFieldInfo):
|
|
||||||
config["read-only"] = "ro"
|
|
||||||
type_description = str(subfield_info.return_type)
|
|
||||||
else:
|
|
||||||
config["read-only"] = "rw"
|
|
||||||
type_description = str(subfield_info.annotation)
|
|
||||||
config["type"] = (
|
|
||||||
type_description.replace("typing.", "")
|
|
||||||
.replace("pathlib.", "")
|
|
||||||
.replace("[", "[ ")
|
|
||||||
.replace("NoneType", "None")
|
|
||||||
)
|
|
||||||
configs.append(config)
|
|
||||||
found_basic = True
|
|
||||||
else:
|
|
||||||
new_parent_types = parent_types + nested_parent_types
|
|
||||||
inner_types.add(nested_type)
|
|
||||||
for nested_field_name, nested_field_info in list(
|
|
||||||
nested_type.model_fields.items()
|
|
||||||
) + list(nested_type.model_computed_fields.items()):
|
|
||||||
extract_nested_models(
|
|
||||||
nested_field_info,
|
|
||||||
new_parent_types + [nested_field_name],
|
|
||||||
)
|
|
||||||
|
|
||||||
extract_nested_models(field_info, [field_name])
|
|
||||||
return sorted(configs, key=lambda x: x["name"])
|
|
||||||
|
|
||||||
|
|
||||||
def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> list[dict]:
|
|
||||||
"""Fetch and process configuration data from the specified EOS server.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
eos_host (Optional[str]): The hostname of the server.
|
|
||||||
eos_port (Optional[Union[str, int]]): The port of the server.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[dict]: A list of processed configuration entries.
|
|
||||||
"""
|
|
||||||
if eos_host is None:
|
|
||||||
eos_host = config_eos.server.host
|
|
||||||
if eos_port is None:
|
|
||||||
eos_port = config_eos.server.port
|
|
||||||
server = f"http://{eos_host}:{eos_port}"
|
|
||||||
|
|
||||||
# Get current configuration from server
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{server}/v1/config")
|
|
||||||
result.raise_for_status()
|
|
||||||
except requests.exceptions.HTTPError as e:
|
|
||||||
detail = result.json()["detail"]
|
|
||||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
|
||||||
logger.warning(warning_msg)
|
|
||||||
return configuration({})
|
|
||||||
config = result.json()
|
|
||||||
|
|
||||||
return configuration(config)
|
|
||||||
|
|
||||||
|
|
||||||
def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Div:
|
|
||||||
"""Create a visual representation of the configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
eos_host (Optional[str]): The hostname of the EOS server.
|
|
||||||
eos_port (Optional[Union[str, int]]): The port of the EOS server.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Table: A `monsterui.franken.Table` component displaying configuration details.
|
|
||||||
"""
|
|
||||||
flds = "Name", "Type", "RO/RW", "Value", "Default", "Description"
|
|
||||||
rows = []
|
|
||||||
last_category = ""
|
|
||||||
for config in get_configuration(eos_host, eos_port):
|
|
||||||
category = config["name"].split(".")[0]
|
|
||||||
if category != last_category:
|
|
||||||
rows.append(P(category))
|
|
||||||
rows.append(DividerLine())
|
|
||||||
last_category = category
|
|
||||||
rows.append(
|
|
||||||
ConfigCard(
|
|
||||||
config["name"],
|
|
||||||
config["type"],
|
|
||||||
config["read-only"],
|
|
||||||
config["value"],
|
|
||||||
config["default"],
|
|
||||||
config["description"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return Div(*rows, cls="space-y-4")
|
|
||||||
|
|
||||||
|
|
||||||
def ConfigurationOrg(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table:
|
|
||||||
"""Create a visual representation of the configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
eos_host (Optional[str]): The hostname of the EOS server.
|
|
||||||
eos_port (Optional[Union[str, int]]): The port of the EOS server.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Table: A `monsterui.franken.Table` component displaying configuration details.
|
|
||||||
"""
|
|
||||||
flds = "Name", "Type", "RO/RW", "Value", "Default", "Description"
|
|
||||||
rows = [
|
|
||||||
Tr(
|
|
||||||
Td(
|
|
||||||
config["name"],
|
|
||||||
cls="max-w-64 text-wrap break-all",
|
|
||||||
),
|
|
||||||
Td(
|
|
||||||
config["type"],
|
|
||||||
cls="max-w-48 text-wrap break-all",
|
|
||||||
),
|
|
||||||
Td(
|
|
||||||
config["read-only"],
|
|
||||||
cls="max-w-24 text-wrap break-all",
|
|
||||||
),
|
|
||||||
Td(
|
|
||||||
config["value"],
|
|
||||||
cls="max-w-md text-wrap break-all",
|
|
||||||
),
|
|
||||||
Td(config["default"], cls="max-w-48 text-wrap break-all"),
|
|
||||||
Td(
|
|
||||||
config["description"],
|
|
||||||
cls="max-w-prose text-wrap",
|
|
||||||
),
|
|
||||||
cls="",
|
|
||||||
)
|
|
||||||
for config in get_configuration(eos_host, eos_port)
|
|
||||||
]
|
|
||||||
head = Thead(*map(Th, flds), cls="text-left")
|
|
||||||
return Table(head, Tbody(*rows), cls="w-full uk-table uk-table-divider uk-table-striped")
|
|
@@ -1,86 +0,0 @@
|
|||||||
{
|
|
||||||
"elecprice": {
|
|
||||||
"charges_kwh": 0.21,
|
|
||||||
"provider": "ElecPriceAkkudoktor"
|
|
||||||
},
|
|
||||||
"general": {
|
|
||||||
"latitude": 52.5,
|
|
||||||
"longitude": 13.4
|
|
||||||
},
|
|
||||||
"prediction": {
|
|
||||||
"historic_hours": 48,
|
|
||||||
"hours": 48
|
|
||||||
},
|
|
||||||
"load": {
|
|
||||||
"provider": "LoadAkkudoktor",
|
|
||||||
"provider_settings": {
|
|
||||||
"loadakkudoktor_year_energy": 20000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"optimization": {
|
|
||||||
"hours": 48
|
|
||||||
},
|
|
||||||
"pvforecast": {
|
|
||||||
"planes": [
|
|
||||||
{
|
|
||||||
"peakpower": 5.0,
|
|
||||||
"surface_azimuth": -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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"provider": "PVForecastAkkudoktor"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"startup_eosdash": true,
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 8503,
|
|
||||||
"eosdash_host": "0.0.0.0",
|
|
||||||
"eosdash_port": 8504
|
|
||||||
},
|
|
||||||
"weather": {
|
|
||||||
"provider": "BrightSky"
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,217 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import requests
|
|
||||||
from bokeh.models import ColumnDataSource, Range1d
|
|
||||||
from bokeh.plotting import figure
|
|
||||||
from monsterui.franken import FT, Grid, P
|
|
||||||
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
|
||||||
from akkudoktoreos.core.pydantic import PydanticDateTimeDataFrame
|
|
||||||
from akkudoktoreos.server.dash.bokeh import Bokeh
|
|
||||||
|
|
||||||
DIR_DEMODATA = Path(__file__).absolute().parent.joinpath("data")
|
|
||||||
FILE_DEMOCONFIG = DIR_DEMODATA.joinpath("democonfig.json")
|
|
||||||
if not FILE_DEMOCONFIG.exists():
|
|
||||||
raise ValueError(f"File does not exist: {FILE_DEMOCONFIG}")
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
# bar width for 1 hour bars (time given in millseconds)
|
|
||||||
BAR_WIDTH_1HOUR = 1000 * 60 * 60
|
|
||||||
|
|
||||||
|
|
||||||
def DemoPVForecast(predictions: pd.DataFrame, config: dict) -> FT:
|
|
||||||
source = ColumnDataSource(predictions)
|
|
||||||
provider = config["pvforecast"]["provider"]
|
|
||||||
|
|
||||||
plot = figure(
|
|
||||||
x_axis_type="datetime",
|
|
||||||
title=f"PV Power Prediction ({provider})",
|
|
||||||
x_axis_label="Datetime",
|
|
||||||
y_axis_label="Power [W]",
|
|
||||||
sizing_mode="stretch_width",
|
|
||||||
height=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
plot.vbar(
|
|
||||||
x="date_time",
|
|
||||||
top="pvforecast_ac_power",
|
|
||||||
source=source,
|
|
||||||
width=BAR_WIDTH_1HOUR * 0.8,
|
|
||||||
legend_label="AC Power",
|
|
||||||
color="lightblue",
|
|
||||||
)
|
|
||||||
|
|
||||||
return Bokeh(plot)
|
|
||||||
|
|
||||||
|
|
||||||
def DemoElectricityPriceForecast(predictions: pd.DataFrame, config: dict) -> FT:
|
|
||||||
source = ColumnDataSource(predictions)
|
|
||||||
provider = config["elecprice"]["provider"]
|
|
||||||
|
|
||||||
plot = figure(
|
|
||||||
x_axis_type="datetime",
|
|
||||||
y_range=Range1d(
|
|
||||||
predictions["elecprice_marketprice_kwh"].min() - 0.1,
|
|
||||||
predictions["elecprice_marketprice_kwh"].max() + 0.1,
|
|
||||||
),
|
|
||||||
title=f"Electricity Price Prediction ({provider})",
|
|
||||||
x_axis_label="Datetime",
|
|
||||||
y_axis_label="Price [€/kWh]",
|
|
||||||
sizing_mode="stretch_width",
|
|
||||||
height=400,
|
|
||||||
)
|
|
||||||
plot.vbar(
|
|
||||||
x="date_time",
|
|
||||||
top="elecprice_marketprice_kwh",
|
|
||||||
source=source,
|
|
||||||
width=BAR_WIDTH_1HOUR * 0.8,
|
|
||||||
legend_label="Market Price",
|
|
||||||
color="lightblue",
|
|
||||||
)
|
|
||||||
|
|
||||||
return Bokeh(plot)
|
|
||||||
|
|
||||||
|
|
||||||
def DemoWeatherTempAir(predictions: pd.DataFrame, config: dict) -> FT:
|
|
||||||
source = ColumnDataSource(predictions)
|
|
||||||
provider = config["weather"]["provider"]
|
|
||||||
|
|
||||||
plot = figure(
|
|
||||||
x_axis_type="datetime",
|
|
||||||
y_range=Range1d(
|
|
||||||
predictions["weather_temp_air"].min() - 1.0, predictions["weather_temp_air"].max() + 1.0
|
|
||||||
),
|
|
||||||
title=f"Air Temperature Prediction ({provider})",
|
|
||||||
x_axis_label="Datetime",
|
|
||||||
y_axis_label="Temperature [°C]",
|
|
||||||
sizing_mode="stretch_width",
|
|
||||||
height=400,
|
|
||||||
)
|
|
||||||
plot.line(
|
|
||||||
"date_time", "weather_temp_air", source=source, legend_label="Air Temperature", color="blue"
|
|
||||||
)
|
|
||||||
|
|
||||||
return Bokeh(plot)
|
|
||||||
|
|
||||||
|
|
||||||
def DemoWeatherIrradiance(predictions: pd.DataFrame, config: dict) -> FT:
|
|
||||||
source = ColumnDataSource(predictions)
|
|
||||||
provider = config["weather"]["provider"]
|
|
||||||
|
|
||||||
plot = figure(
|
|
||||||
x_axis_type="datetime",
|
|
||||||
title=f"Irradiance Prediction ({provider})",
|
|
||||||
x_axis_label="Datetime",
|
|
||||||
y_axis_label="Irradiance [W/m2]",
|
|
||||||
sizing_mode="stretch_width",
|
|
||||||
height=400,
|
|
||||||
)
|
|
||||||
plot.line(
|
|
||||||
"date_time",
|
|
||||||
"weather_ghi",
|
|
||||||
source=source,
|
|
||||||
legend_label="Global Horizontal Irradiance",
|
|
||||||
color="red",
|
|
||||||
)
|
|
||||||
plot.line(
|
|
||||||
"date_time",
|
|
||||||
"weather_dni",
|
|
||||||
source=source,
|
|
||||||
legend_label="Direct Normal Irradiance",
|
|
||||||
color="green",
|
|
||||||
)
|
|
||||||
plot.line(
|
|
||||||
"date_time",
|
|
||||||
"weather_dhi",
|
|
||||||
source=source,
|
|
||||||
legend_label="Diffuse Horizontal Irradiance",
|
|
||||||
color="blue",
|
|
||||||
)
|
|
||||||
|
|
||||||
return Bokeh(plot)
|
|
||||||
|
|
||||||
|
|
||||||
def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
|
|
||||||
server = f"http://{eos_host}:{eos_port}"
|
|
||||||
|
|
||||||
# Get current configuration from server
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{server}/v1/config")
|
|
||||||
result.raise_for_status()
|
|
||||||
except requests.exceptions.HTTPError as err:
|
|
||||||
detail = result.json()["detail"]
|
|
||||||
return P(
|
|
||||||
f"Can not retrieve configuration from {server}: {err}, {detail}",
|
|
||||||
cls="text-center",
|
|
||||||
)
|
|
||||||
config = result.json()
|
|
||||||
|
|
||||||
# Set demo configuration
|
|
||||||
with FILE_DEMOCONFIG.open("r", encoding="utf-8") as fd:
|
|
||||||
democonfig = json.load(fd)
|
|
||||||
try:
|
|
||||||
result = requests.put(f"{server}/v1/config", json=democonfig)
|
|
||||||
result.raise_for_status()
|
|
||||||
except requests.exceptions.HTTPError as err:
|
|
||||||
detail = result.json()["detail"]
|
|
||||||
# Try to reset to original config
|
|
||||||
requests.put(f"{server}/v1/config", json=config)
|
|
||||||
return P(
|
|
||||||
f"Can not set demo configuration on {server}: {err}, {detail}",
|
|
||||||
cls="text-center",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update all predictions
|
|
||||||
try:
|
|
||||||
result = requests.post(f"{server}/v1/prediction/update")
|
|
||||||
result.raise_for_status()
|
|
||||||
except requests.exceptions.HTTPError as err:
|
|
||||||
detail = result.json()["detail"]
|
|
||||||
# Try to reset to original config
|
|
||||||
requests.put(f"{server}/v1/config", json=config)
|
|
||||||
return P(
|
|
||||||
f"Can not update predictions on {server}: {err}, {detail}",
|
|
||||||
cls="text-center",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get Forecasts
|
|
||||||
try:
|
|
||||||
params = {
|
|
||||||
"keys": [
|
|
||||||
"pvforecast_ac_power",
|
|
||||||
"elecprice_marketprice_kwh",
|
|
||||||
"weather_temp_air",
|
|
||||||
"weather_ghi",
|
|
||||||
"weather_dni",
|
|
||||||
"weather_dhi",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
result = requests.get(f"{server}/v1/prediction/dataframe", params=params)
|
|
||||||
result.raise_for_status()
|
|
||||||
predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe()
|
|
||||||
except requests.exceptions.HTTPError as err:
|
|
||||||
detail = result.json()["detail"]
|
|
||||||
return P(
|
|
||||||
f"Can not retrieve predictions from {server}: {err}, {detail}",
|
|
||||||
cls="text-center",
|
|
||||||
)
|
|
||||||
except Exception as err:
|
|
||||||
return P(
|
|
||||||
f"Can not retrieve predictions from {server}: {err}",
|
|
||||||
cls="text-center",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reset to original config
|
|
||||||
requests.put(f"{server}/v1/config", json=config)
|
|
||||||
|
|
||||||
return Grid(
|
|
||||||
DemoPVForecast(predictions, democonfig),
|
|
||||||
DemoElectricityPriceForecast(predictions, democonfig),
|
|
||||||
DemoWeatherTempAir(predictions, democonfig),
|
|
||||||
DemoWeatherIrradiance(predictions, democonfig),
|
|
||||||
cols_max=2,
|
|
||||||
)
|
|
@@ -1,92 +0,0 @@
|
|||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from monsterui.daisy import Loading, LoadingT
|
|
||||||
from monsterui.franken import A, ButtonT, DivFullySpaced, P
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
|
|
||||||
from akkudoktoreos.config.config import get_config
|
|
||||||
from akkudoktoreos.core.logging import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
config_eos = get_config()
|
|
||||||
|
|
||||||
|
|
||||||
def get_alive(eos_host: str, eos_port: Union[str, int]) -> str:
|
|
||||||
"""Fetch alive information from the specified EOS server.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
eos_host (str): The hostname of the server.
|
|
||||||
eos_port (Union[str, int]): The port of the server.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Alive data.
|
|
||||||
"""
|
|
||||||
result = requests.Response()
|
|
||||||
try:
|
|
||||||
result = requests.get(f"http://{eos_host}:{eos_port}/v1/health")
|
|
||||||
if result.status_code == 200:
|
|
||||||
alive = result.json()["status"]
|
|
||||||
else:
|
|
||||||
alive = f"Server responded with status code: {result.status_code}"
|
|
||||||
except RequestException as e:
|
|
||||||
warning_msg = f"{e}"
|
|
||||||
logger.warning(warning_msg)
|
|
||||||
alive = warning_msg
|
|
||||||
|
|
||||||
return alive
|
|
||||||
|
|
||||||
|
|
||||||
def Footer(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> str:
|
|
||||||
if eos_host is None:
|
|
||||||
eos_host = config_eos.server.host
|
|
||||||
if eos_port is None:
|
|
||||||
eos_port = config_eos.server.port
|
|
||||||
alive_icon = None
|
|
||||||
if eos_host is None or eos_port is None:
|
|
||||||
alive = "EOS server not given: {eos_host}:{eos_port}"
|
|
||||||
else:
|
|
||||||
alive = get_alive(eos_host, eos_port)
|
|
||||||
if alive == "alive":
|
|
||||||
alive_icon = Loading(
|
|
||||||
cls=(
|
|
||||||
LoadingT.ring,
|
|
||||||
LoadingT.sm,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
alive = f"EOS {eos_host}:{eos_port}"
|
|
||||||
if alive_icon:
|
|
||||||
alive_cls = f"{ButtonT.primary} uk-link rounded-md"
|
|
||||||
else:
|
|
||||||
alive_cls = f"{ButtonT.secondary} uk-link rounded-md"
|
|
||||||
return DivFullySpaced(
|
|
||||||
P(
|
|
||||||
alive_icon,
|
|
||||||
A(alive, href=f"http://{eos_host}:{eos_port}/docs", target="_blank", cls=alive_cls),
|
|
||||||
),
|
|
||||||
P(
|
|
||||||
A(
|
|
||||||
"Documentation",
|
|
||||||
href="https://akkudoktor-eos.readthedocs.io/en/latest/",
|
|
||||||
target="_blank",
|
|
||||||
cls="uk-link",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
P(
|
|
||||||
A(
|
|
||||||
"Issues",
|
|
||||||
href="https://github.com/Akkudoktor-EOS/EOS/issues",
|
|
||||||
target="_blank",
|
|
||||||
cls="uk-link",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
P(
|
|
||||||
A(
|
|
||||||
"GitHub",
|
|
||||||
href="https://github.com/Akkudoktor-EOS/EOS/",
|
|
||||||
target="_blank",
|
|
||||||
cls="uk-link",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cls="uk-padding-remove-top uk-padding-remove-botton",
|
|
||||||
)
|
|
@@ -1,24 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from fasthtml.common import Div
|
|
||||||
|
|
||||||
from akkudoktoreos.server.dash.markdown import Markdown
|
|
||||||
|
|
||||||
hello_md = """
|
|
||||||
|
|
||||||
# Akkudoktor EOSdash
|
|
||||||
|
|
||||||
The dashboard for Akkudoktor EOS.
|
|
||||||
|
|
||||||
EOS provides a comprehensive solution for simulating and optimizing an energy system based
|
|
||||||
on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries),
|
|
||||||
load management (consumer requirements), heat pumps, electric vehicles, and consideration of
|
|
||||||
electricity price data, this system enables forecasting and optimization of energy flow and costs
|
|
||||||
over a specified period.
|
|
||||||
|
|
||||||
Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/).
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def Hello(**kwargs: Any) -> Div:
|
|
||||||
return Markdown(hello_md, **kwargs)
|
|
@@ -1,136 +0,0 @@
|
|||||||
"""Markdown rendering with MonsterUI HTML classes."""
|
|
||||||
|
|
||||||
from typing import Any, List, Optional, Union
|
|
||||||
|
|
||||||
from fasthtml.common import FT, Div, NotStr
|
|
||||||
from markdown_it import MarkdownIt
|
|
||||||
from markdown_it.renderer import RendererHTML
|
|
||||||
from markdown_it.token import Token
|
|
||||||
from monsterui.foundations import stringify
|
|
||||||
|
|
||||||
|
|
||||||
def render_heading(
|
|
||||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
|
||||||
) -> str:
|
|
||||||
"""Custom renderer for Markdown headings.
|
|
||||||
|
|
||||||
Adds specific CSS classes based on the heading level.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
self: The renderer instance.
|
|
||||||
tokens: List of tokens to be rendered.
|
|
||||||
idx: Index of the current token.
|
|
||||||
options: Rendering options.
|
|
||||||
env: Environment sandbox for plugins.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The rendered token as a string.
|
|
||||||
"""
|
|
||||||
if tokens[idx].markup == "#":
|
|
||||||
tokens[idx].attrSet("class", "uk-heading-divider uk-h1 uk-margin")
|
|
||||||
elif tokens[idx].markup == "##":
|
|
||||||
tokens[idx].attrSet("class", "uk-heading-divider uk-h2 uk-margin")
|
|
||||||
elif tokens[idx].markup == "###":
|
|
||||||
tokens[idx].attrSet("class", "uk-heading-divider uk-h3 uk-margin")
|
|
||||||
elif tokens[idx].markup == "####":
|
|
||||||
tokens[idx].attrSet("class", "uk-heading-divider uk-h4 uk-margin")
|
|
||||||
|
|
||||||
# pass token to default renderer.
|
|
||||||
return self.renderToken(tokens, idx, options, env)
|
|
||||||
|
|
||||||
|
|
||||||
def render_paragraph(
|
|
||||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
|
||||||
) -> str:
|
|
||||||
"""Custom renderer for Markdown paragraphs.
|
|
||||||
|
|
||||||
Adds specific CSS classes.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
self: The renderer instance.
|
|
||||||
tokens: List of tokens to be rendered.
|
|
||||||
idx: Index of the current token.
|
|
||||||
options: Rendering options.
|
|
||||||
env: Environment sandbox for plugins.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The rendered token as a string.
|
|
||||||
"""
|
|
||||||
tokens[idx].attrSet("class", "uk-paragraph")
|
|
||||||
|
|
||||||
# pass token to default renderer.
|
|
||||||
return self.renderToken(tokens, idx, options, env)
|
|
||||||
|
|
||||||
|
|
||||||
def render_blockquote(
|
|
||||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
|
||||||
) -> str:
|
|
||||||
"""Custom renderer for Markdown blockquotes.
|
|
||||||
|
|
||||||
Adds specific CSS classes.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
self: The renderer instance.
|
|
||||||
tokens: List of tokens to be rendered.
|
|
||||||
idx: Index of the current token.
|
|
||||||
options: Rendering options.
|
|
||||||
env: Environment sandbox for plugins.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The rendered token as a string.
|
|
||||||
"""
|
|
||||||
tokens[idx].attrSet("class", "uk-blockquote")
|
|
||||||
|
|
||||||
# pass token to default renderer.
|
|
||||||
return self.renderToken(tokens, idx, options, env)
|
|
||||||
|
|
||||||
|
|
||||||
def render_link(self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict) -> str:
|
|
||||||
"""Custom renderer for Markdown links.
|
|
||||||
|
|
||||||
Adds the target attribute to open links in a new tab.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
self: The renderer instance.
|
|
||||||
tokens: List of tokens to be rendered.
|
|
||||||
idx: Index of the current token.
|
|
||||||
options: Rendering options.
|
|
||||||
env: Environment sandbox for plugins.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The rendered token as a string.
|
|
||||||
"""
|
|
||||||
tokens[idx].attrSet("class", "uk-link")
|
|
||||||
tokens[idx].attrSet("target", "_blank")
|
|
||||||
|
|
||||||
# pass token to default renderer.
|
|
||||||
return self.renderToken(tokens, idx, options, env)
|
|
||||||
|
|
||||||
|
|
||||||
markdown = MarkdownIt("gfm-like")
|
|
||||||
markdown.add_render_rule("heading_open", render_heading)
|
|
||||||
markdown.add_render_rule("paragraph_open", render_paragraph)
|
|
||||||
markdown.add_render_rule("blockquote_open", render_blockquote)
|
|
||||||
markdown.add_render_rule("link_open", render_link)
|
|
||||||
|
|
||||||
|
|
||||||
markdown_cls = "bg-background text-lg ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
|
|
||||||
|
|
||||||
def Markdown(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> FT:
|
|
||||||
"""Component to render Markdown content with custom styling.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
c: Markdown content to be rendered.
|
|
||||||
cls: Optional additional CSS classes to be added.
|
|
||||||
kwargs: Additional keyword arguments for the Div component.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An FT object representing the rendered HTML content wrapped in a Div component.
|
|
||||||
"""
|
|
||||||
new_cls = markdown_cls
|
|
||||||
if cls:
|
|
||||||
new_cls += f" {stringify(cls)}"
|
|
||||||
kwargs["cls"] = new_cls
|
|
||||||
md_html = markdown.render(*c)
|
|
||||||
return Div(NotStr(md_html), **kwargs)
|
|
@@ -1,32 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import signal
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
|
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
|
||||||
|
|
||||||
import psutil
|
import httpx
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import Body, FastAPI
|
from fastapi import FastAPI, Query, Request
|
||||||
from fastapi import Path as FastapiPath
|
|
||||||
from fastapi import Query, Request
|
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import (
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
||||||
FileResponse,
|
|
||||||
HTMLResponse,
|
|
||||||
JSONResponse,
|
|
||||||
RedirectResponse,
|
|
||||||
Response,
|
|
||||||
)
|
|
||||||
|
|
||||||
from akkudoktoreos.config.config import ConfigEOS, SettingsEOS, get_config
|
from akkudoktoreos.config.config import ConfigEOS, SettingsEOS, get_config
|
||||||
from akkudoktoreos.core.cache import CacheFileStore
|
|
||||||
from akkudoktoreos.core.ems import get_ems
|
from akkudoktoreos.core.ems import get_ems
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import (
|
from akkudoktoreos.core.pydantic import (
|
||||||
@@ -46,9 +34,6 @@ from akkudoktoreos.prediction.load import LoadCommonSettings
|
|||||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||||
from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction
|
from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction
|
||||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||||
from akkudoktoreos.server.rest.error import create_error_page
|
|
||||||
from akkudoktoreos.server.rest.tasks import repeat_every
|
|
||||||
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
|
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -60,54 +45,133 @@ ems_eos = get_ems()
|
|||||||
# Command line arguments
|
# Command line arguments
|
||||||
args = None
|
args = None
|
||||||
|
|
||||||
|
ERROR_PAGE_TEMPLATE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Energy Optimization System (EOS) Error</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.error-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.error-code {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e53e3e;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #2d3748;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.error-details {
|
||||||
|
background: #f7fafc;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.back-button {
|
||||||
|
background: #3182ce;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.back-button:hover {
|
||||||
|
background: #2c5282;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<h1 class="error-code">STATUS_CODE</h1>
|
||||||
|
<h2 class="error-title">ERROR_TITLE</h2>
|
||||||
|
<p class="error-message">ERROR_MESSAGE</p>
|
||||||
|
<div class="error-details">ERROR_DETAILS</div>
|
||||||
|
<a href="/docs" class="back-button">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_error_page(
|
||||||
|
status_code: str, error_title: str, error_message: str, error_details: str
|
||||||
|
) -> str:
|
||||||
|
"""Create an error page by replacing placeholders in the template."""
|
||||||
|
return (
|
||||||
|
ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code)
|
||||||
|
.replace("ERROR_TITLE", error_title)
|
||||||
|
.replace("ERROR_MESSAGE", error_message)
|
||||||
|
.replace("ERROR_DETAILS", error_details)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ----------------------
|
# ----------------------
|
||||||
# EOSdash server startup
|
# EOSdash server startup
|
||||||
# ----------------------
|
# ----------------------
|
||||||
|
|
||||||
|
|
||||||
def start_eosdash(
|
def start_eosdash() -> subprocess.Popen:
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
eos_host: str,
|
|
||||||
eos_port: int,
|
|
||||||
log_level: str,
|
|
||||||
access_log: bool,
|
|
||||||
reload: bool,
|
|
||||||
eos_dir: str,
|
|
||||||
eos_config_dir: str,
|
|
||||||
) -> subprocess.Popen:
|
|
||||||
"""Start the EOSdash server as a subprocess.
|
"""Start the EOSdash server as a subprocess.
|
||||||
|
|
||||||
This function starts the EOSdash server by launching it as a subprocess. It checks if the server
|
|
||||||
is already running on the specified port and either returns the existing process or starts a new one.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
host (str): The hostname for the EOSdash server.
|
|
||||||
port (int): The port for the EOSdash server.
|
|
||||||
eos_host (str): The hostname for the EOS server.
|
|
||||||
eos_port (int): The port for the EOS server.
|
|
||||||
log_level (str): The logging level for the EOSdash server.
|
|
||||||
access_log (bool): Flag to enable or disable access logging.
|
|
||||||
reload (bool): Flag to enable or disable auto-reloading.
|
|
||||||
eos_dir (str): Path to the EOS data directory.
|
|
||||||
eos_config_dir (str): Path to the EOS configuration directory.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
subprocess.Popen: The process of the EOSdash server.
|
server_process: The process of the EOSdash server
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If the EOSdash server fails to start.
|
|
||||||
"""
|
"""
|
||||||
eosdash_path = Path(__file__).parent.resolve().joinpath("eosdash.py")
|
eosdash_path = Path(__file__).parent.resolve().joinpath("eosdash.py")
|
||||||
|
|
||||||
# Do a one time check for port free to generate warnings if not so
|
if args is None:
|
||||||
wait_for_port_free(port, timeout=0, waiting_app_name="EOSdash")
|
# No command line arguments
|
||||||
|
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)
|
||||||
|
eos_host = args.host
|
||||||
|
eos_port = args.port
|
||||||
|
log_level = args.log_level
|
||||||
|
access_log = args.access_log
|
||||||
|
reload = args.reload
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
str(eosdash_path),
|
||||||
"akkudoktoreos.server.eosdash",
|
|
||||||
"--host",
|
"--host",
|
||||||
str(host),
|
str(host),
|
||||||
"--port",
|
"--port",
|
||||||
@@ -123,23 +187,11 @@ def start_eosdash(
|
|||||||
"--reload",
|
"--reload",
|
||||||
str(reload),
|
str(reload),
|
||||||
]
|
]
|
||||||
# Set environment before any subprocess run, to keep custom config dir
|
server_process = subprocess.Popen(
|
||||||
env = os.environ.copy()
|
cmd,
|
||||||
env["EOS_DIR"] = eos_dir
|
stdout=subprocess.PIPE,
|
||||||
env["EOS_CONFIG_DIR"] = eos_config_dir
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
try:
|
|
||||||
server_process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError as ex:
|
|
||||||
error_msg = f"Could not start EOSdash: {ex}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
raise RuntimeError(error_msg)
|
|
||||||
|
|
||||||
return server_process
|
return server_process
|
||||||
|
|
||||||
@@ -149,130 +201,20 @@ def start_eosdash(
|
|||||||
# ----------------------
|
# ----------------------
|
||||||
|
|
||||||
|
|
||||||
def cache_clear(clear_all: Optional[bool] = None) -> None:
|
|
||||||
"""Cleanup expired cache files."""
|
|
||||||
if clear_all:
|
|
||||||
CacheFileStore().clear(clear_all=True)
|
|
||||||
else:
|
|
||||||
CacheFileStore().clear(before_datetime=to_datetime())
|
|
||||||
|
|
||||||
|
|
||||||
def cache_load() -> dict:
|
|
||||||
"""Load cache from cachefilestore.json."""
|
|
||||||
return CacheFileStore().load_store()
|
|
||||||
|
|
||||||
|
|
||||||
def cache_save() -> dict:
|
|
||||||
"""Save cache to cachefilestore.json."""
|
|
||||||
return CacheFileStore().save_store()
|
|
||||||
|
|
||||||
|
|
||||||
@repeat_every(seconds=float(config_eos.cache.cleanup_interval))
|
|
||||||
def cache_cleanup_task() -> None:
|
|
||||||
"""Repeating task to clear cache from expired cache files."""
|
|
||||||
cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
@repeat_every(
|
|
||||||
seconds=10,
|
|
||||||
wait_first=config_eos.ems.startup_delay,
|
|
||||||
)
|
|
||||||
def energy_management_task() -> None:
|
|
||||||
"""Repeating task for energy management."""
|
|
||||||
ems_eos.manage_energy()
|
|
||||||
|
|
||||||
|
|
||||||
async def server_shutdown_task() -> None:
|
|
||||||
"""One-shot task for shutting down the EOS server.
|
|
||||||
|
|
||||||
This coroutine performs the following actions:
|
|
||||||
1. Ensures the cache is saved by calling the cache_save function.
|
|
||||||
2. Waits for 5 seconds to allow the EOS server to complete any ongoing tasks.
|
|
||||||
3. Gracefully shuts down the current process by sending the appropriate signal.
|
|
||||||
|
|
||||||
If running on Windows, the CTRL_C_EVENT signal is sent to terminate the process.
|
|
||||||
On other operating systems, the SIGTERM signal is used.
|
|
||||||
|
|
||||||
Finally, logs a message indicating that the EOS server has been terminated.
|
|
||||||
"""
|
|
||||||
# Assure cache is saved
|
|
||||||
cache_save()
|
|
||||||
|
|
||||||
# Give EOS time to finish some work
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
# Gracefully shut down this process.
|
|
||||||
pid = psutil.Process().pid
|
|
||||||
if os.name == "nt":
|
|
||||||
os.kill(pid, signal.CTRL_C_EVENT) # type: ignore[attr-defined,unused-ignore]
|
|
||||||
else:
|
|
||||||
os.kill(pid, signal.SIGTERM) # type: ignore[attr-defined,unused-ignore]
|
|
||||||
|
|
||||||
logger.info(f"🚀 EOS terminated, PID {pid}")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Lifespan manager for the app."""
|
"""Lifespan manager for the app."""
|
||||||
# On startup
|
# On startup
|
||||||
if config_eos.server.startup_eosdash:
|
if config_eos.server.startup_eosdash:
|
||||||
try:
|
try:
|
||||||
if args is None:
|
eosdash_process = start_eosdash()
|
||||||
# No command line arguments
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
eos_host = args.host
|
|
||||||
eos_port = args.port
|
|
||||||
log_level = args.log_level
|
|
||||||
access_log = args.access_log
|
|
||||||
reload = args.reload
|
|
||||||
|
|
||||||
host = host if host else get_default_host()
|
|
||||||
port = port if port else 8504
|
|
||||||
eos_host = eos_host if eos_host else get_default_host()
|
|
||||||
eos_port = eos_port if eos_port else 8503
|
|
||||||
|
|
||||||
eos_dir = str(config_eos.general.data_folder_path)
|
|
||||||
eos_config_dir = str(config_eos.general.config_folder_path)
|
|
||||||
|
|
||||||
eosdash_process = start_eosdash(
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
eos_host=eos_host,
|
|
||||||
eos_port=eos_port,
|
|
||||||
log_level=log_level,
|
|
||||||
access_log=access_log,
|
|
||||||
reload=reload,
|
|
||||||
eos_dir=eos_dir,
|
|
||||||
eos_config_dir=eos_config_dir,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start EOSdash server. Error: {e}")
|
logger.error(f"Failed to start EOSdash server. Error: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cache_load()
|
|
||||||
if config_eos.cache.cleanup_interval is None:
|
|
||||||
logger.warning("Cache file cleanup disabled. Set cache.cleanup_interval.")
|
|
||||||
else:
|
|
||||||
await cache_cleanup_task()
|
|
||||||
await energy_management_task()
|
|
||||||
|
|
||||||
# Handover to application
|
# Handover to application
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# On shutdown
|
# On shutdown
|
||||||
cache_save()
|
# nothing to do
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -285,138 +227,18 @@ app = FastAPI(
|
|||||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||||
},
|
},
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
root_path=str(Path(__file__).parent),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server_dir = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
|
|
||||||
class PdfResponse(FileResponse):
|
class PdfResponse(FileResponse):
|
||||||
media_type = "application/pdf"
|
media_type = "application/pdf"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/admin/cache/clear", tags=["admin"])
|
@app.put("/v1/config/reset", tags=["config"])
|
||||||
def fastapi_admin_cache_clear_post(clear_all: Optional[bool] = None) -> dict:
|
def fastapi_config_update_post() -> ConfigEOS:
|
||||||
"""Clear the cache from expired data.
|
|
||||||
|
|
||||||
Deletes expired cache files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
clear_all (Optional[bool]): Delete all cached files. Default is False.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): The management data after cleanup.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cache_clear(clear_all=clear_all)
|
|
||||||
data = CacheFileStore().current_store()
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Error on cache clear: {e}")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/admin/cache/save", tags=["admin"])
|
|
||||||
def fastapi_admin_cache_save_post() -> dict:
|
|
||||||
"""Save the current cache management data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): The management data that was saved.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = cache_save()
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Error on cache save: {e}")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/admin/cache/load", tags=["admin"])
|
|
||||||
def fastapi_admin_cache_load_post() -> dict:
|
|
||||||
"""Load cache management data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): The management data that was loaded.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = cache_save()
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Error on cache load: {e}")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/admin/cache", tags=["admin"])
|
|
||||||
def fastapi_admin_cache_get() -> dict:
|
|
||||||
"""Current cache management data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): The management data.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = CacheFileStore().current_store()
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Error on cache data retrieval: {e}")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/admin/server/restart", tags=["admin"])
|
|
||||||
async def fastapi_admin_server_restart_post() -> dict:
|
|
||||||
"""Restart the server.
|
|
||||||
|
|
||||||
Restart EOS properly by starting a new instance before exiting the old one.
|
|
||||||
"""
|
|
||||||
logger.info("🔄 Restarting EOS...")
|
|
||||||
|
|
||||||
# Start a new EOS (Uvicorn) process
|
|
||||||
# Force a new process group to make the new process easily distinguishable from the current one
|
|
||||||
# Set environment before any subprocess run, to keep custom config dir
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["EOS_DIR"] = str(config_eos.general.data_folder_path)
|
|
||||||
env["EOS_CONFIG_DIR"] = str(config_eos.general.config_folder_path)
|
|
||||||
|
|
||||||
new_process = subprocess.Popen(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
]
|
|
||||||
+ sys.argv,
|
|
||||||
env=env,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
logger.info(f"🚀 EOS restarted, PID {new_process.pid}")
|
|
||||||
|
|
||||||
# Gracefully shut down this process.
|
|
||||||
asyncio.create_task(server_shutdown_task())
|
|
||||||
|
|
||||||
# Will be executed because shutdown is delegated to async coroutine
|
|
||||||
return {
|
|
||||||
"message": "Restarting EOS...",
|
|
||||||
"pid": new_process.pid,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/admin/server/shutdown", tags=["admin"])
|
|
||||||
async def fastapi_admin_server_shutdown_post() -> dict:
|
|
||||||
"""Shutdown the server."""
|
|
||||||
logger.info("🔄 Stopping EOS...")
|
|
||||||
|
|
||||||
# Gracefully shut down this process.
|
|
||||||
asyncio.create_task(server_shutdown_task())
|
|
||||||
|
|
||||||
# Will be executed because shutdown is delegated to async coroutine
|
|
||||||
return {
|
|
||||||
"message": "Stopping EOS...",
|
|
||||||
"pid": psutil.Process().pid,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/health")
|
|
||||||
def fastapi_health_get(): # type: ignore
|
|
||||||
"""Health check endpoint to verify that the EOS server is alive."""
|
|
||||||
return JSONResponse(
|
|
||||||
{
|
|
||||||
"status": "alive",
|
|
||||||
"pid": psutil.Process().pid,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/config/reset", tags=["config"])
|
|
||||||
def fastapi_config_reset_post() -> ConfigEOS:
|
|
||||||
"""Reset the configuration to the EOS configuration file.
|
"""Reset the configuration to the EOS configuration file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -427,7 +249,7 @@ def fastapi_config_reset_post() -> ConfigEOS:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"Cannot reset configuration: {e}",
|
detail=f"Cannot update configuration from file '{config_eos.config_file_path}': {e}",
|
||||||
)
|
)
|
||||||
return config_eos
|
return config_eos
|
||||||
|
|
||||||
@@ -480,58 +302,6 @@ def fastapi_config_put(settings: SettingsEOS) -> ConfigEOS:
|
|||||||
return config_eos
|
return config_eos
|
||||||
|
|
||||||
|
|
||||||
@app.put("/v1/config/{path:path}", tags=["config"])
|
|
||||||
def fastapi_config_put_key(
|
|
||||||
path: str = FastapiPath(
|
|
||||||
..., description="The nested path to the configuration key (e.g., general/latitude)."
|
|
||||||
),
|
|
||||||
value: Any = Body(..., description="The value to assign to the specified configuration path."),
|
|
||||||
) -> ConfigEOS:
|
|
||||||
"""Update a nested key or index in the config model.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
|
|
||||||
value (Any): The new value to assign to the key or index at path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
configuration (ConfigEOS): The current configuration after the update.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_eos.set_config_value(path, value)
|
|
||||||
except IndexError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except KeyError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
return config_eos
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/config/{path:path}", tags=["config"])
|
|
||||||
def fastapi_config_get_key(
|
|
||||||
path: str = FastapiPath(
|
|
||||||
..., description="The nested path to the configuration key (e.g., general/latitude)."
|
|
||||||
),
|
|
||||||
) -> Response:
|
|
||||||
"""Get the value of a nested key or index in the config model.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
value (Any): The value of the selected nested key.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return config_eos.get_config_value(path)
|
|
||||||
except IndexError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except KeyError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/measurement/keys", tags=["measurement"])
|
@app.get("/v1/measurement/keys", tags=["measurement"])
|
||||||
def fastapi_measurement_keys_get() -> list[str]:
|
def fastapi_measurement_keys_get() -> list[str]:
|
||||||
"""Get a list of available measurement keys."""
|
"""Get a list of available measurement keys."""
|
||||||
@@ -706,49 +476,6 @@ def fastapi_prediction_series_get(
|
|||||||
return PydanticDateTimeSeries.from_series(pdseries)
|
return PydanticDateTimeSeries.from_series(pdseries)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/prediction/dataframe", tags=["prediction"])
|
|
||||||
def fastapi_prediction_dataframe_get(
|
|
||||||
keys: Annotated[list[str], Query(description="Prediction keys.")],
|
|
||||||
start_datetime: Annotated[
|
|
||||||
Optional[str],
|
|
||||||
Query(description="Starting datetime (inclusive)."),
|
|
||||||
] = None,
|
|
||||||
end_datetime: Annotated[
|
|
||||||
Optional[str],
|
|
||||||
Query(description="Ending datetime (exclusive)."),
|
|
||||||
] = None,
|
|
||||||
interval: Annotated[
|
|
||||||
Optional[str],
|
|
||||||
Query(description="Time duration for each interval. Defaults to 1 hour."),
|
|
||||||
] = None,
|
|
||||||
) -> PydanticDateTimeDataFrame:
|
|
||||||
"""Get prediction for given key within given date range as series.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key (str): Prediction key
|
|
||||||
start_datetime (Optional[str]): Starting datetime (inclusive).
|
|
||||||
Defaults to start datetime of latest prediction.
|
|
||||||
end_datetime (Optional[str]: Ending datetime (exclusive).
|
|
||||||
|
|
||||||
Defaults to end datetime of latest prediction.
|
|
||||||
"""
|
|
||||||
for key in keys:
|
|
||||||
if key not in prediction_eos.record_keys:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
|
||||||
if start_datetime is None:
|
|
||||||
start_datetime = prediction_eos.start_datetime
|
|
||||||
else:
|
|
||||||
start_datetime = to_datetime(start_datetime)
|
|
||||||
if end_datetime is None:
|
|
||||||
end_datetime = prediction_eos.end_datetime
|
|
||||||
else:
|
|
||||||
end_datetime = to_datetime(end_datetime)
|
|
||||||
df = prediction_eos.keys_to_dataframe(
|
|
||||||
keys=keys, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval
|
|
||||||
)
|
|
||||||
return PydanticDateTimeDataFrame.from_dataframe(df, tz=config_eos.general.timezone)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/prediction/list", tags=["prediction"])
|
@app.get("/v1/prediction/list", tags=["prediction"])
|
||||||
def fastapi_prediction_list_get(
|
def fastapi_prediction_list_get(
|
||||||
key: Annotated[str, Query(description="Prediction key.")],
|
key: Annotated[str, Query(description="Prediction key.")],
|
||||||
@@ -762,7 +489,7 @@ def fastapi_prediction_list_get(
|
|||||||
] = None,
|
] = None,
|
||||||
interval: Annotated[
|
interval: Annotated[
|
||||||
Optional[str],
|
Optional[str],
|
||||||
Query(description="Time duration for each interval. Defaults to 1 hour."),
|
Query(description="Time duration for each interval."),
|
||||||
] = None,
|
] = None,
|
||||||
) -> List[Any]:
|
) -> List[Any]:
|
||||||
"""Get prediction for given key within given date range as value list.
|
"""Get prediction for given key within given date range as value list.
|
||||||
@@ -799,40 +526,8 @@ def fastapi_prediction_list_get(
|
|||||||
return prediction_list
|
return prediction_list
|
||||||
|
|
||||||
|
|
||||||
@app.put("/v1/prediction/import/{provider_id}", tags=["prediction"])
|
|
||||||
def fastapi_prediction_import_provider(
|
|
||||||
provider_id: str = FastapiPath(..., description="Provider ID."),
|
|
||||||
data: Optional[Union[PydanticDateTimeDataFrame, PydanticDateTimeData, dict]] = None,
|
|
||||||
force_enable: Optional[bool] = None,
|
|
||||||
) -> Response:
|
|
||||||
"""Import prediction for given provider ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_id: ID of provider to update.
|
|
||||||
data: Prediction data.
|
|
||||||
force_enable: Update data even if provider is disabled.
|
|
||||||
Defaults to False.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
provider = prediction_eos.provider_by_id(provider_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not found.")
|
|
||||||
if not provider.enabled() and not force_enable:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not enabled.")
|
|
||||||
try:
|
|
||||||
provider.import_from_json(json_str=json.dumps(data))
|
|
||||||
provider.update_datetime = to_datetime(in_timezone=config_eos.general.timezone)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail=f"Error on import for provider '{provider_id}': {e}"
|
|
||||||
)
|
|
||||||
return Response()
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/prediction/update", tags=["prediction"])
|
@app.post("/v1/prediction/update", tags=["prediction"])
|
||||||
def fastapi_prediction_update(
|
def fastapi_prediction_update(force_update: bool = False, force_enable: bool = False) -> Response:
|
||||||
force_update: Optional[bool] = False, force_enable: Optional[bool] = False
|
|
||||||
) -> Response:
|
|
||||||
"""Update predictions for all providers.
|
"""Update predictions for all providers.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -844,7 +539,8 @@ def fastapi_prediction_update(
|
|||||||
try:
|
try:
|
||||||
prediction_eos.update_data(force_update=force_update, force_enable=force_enable)
|
prediction_eos.update_data(force_update=force_update, force_enable=force_enable)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Error on prediction update: {e}")
|
raise e
|
||||||
|
# raise HTTPException(status_code=400, detail=f"Error on update of provider: {e}")
|
||||||
return Response()
|
return Response()
|
||||||
|
|
||||||
|
|
||||||
@@ -1160,66 +856,74 @@ def site_map() -> RedirectResponse:
|
|||||||
return RedirectResponse(url="/docs")
|
return RedirectResponse(url="/docs")
|
||||||
|
|
||||||
|
|
||||||
# Keep the redirect last to handle all requests that are not taken by the Rest API.
|
# Keep the proxy last to handle all requests that are not taken by the Rest API.
|
||||||
|
|
||||||
|
if config_eos.server.startup_eosdash:
|
||||||
|
|
||||||
|
@app.delete("/{path:path}", include_in_schema=False)
|
||||||
|
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.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.get("/", include_in_schema=False)
|
||||||
|
def root() -> RedirectResponse:
|
||||||
|
return RedirectResponse(url="/docs")
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/{path:path}", include_in_schema=False)
|
async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse | HTMLResponse]:
|
||||||
async def redirect_delete(request: Request, path: str) -> Response:
|
if config_eos.server.eosdash_host and config_eos.server.eosdash_port:
|
||||||
return redirect(request, path)
|
# Proxy to EOSdash server
|
||||||
|
url = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}/{path}"
|
||||||
|
headers = dict(request.headers)
|
||||||
|
|
||||||
|
data = await request.body()
|
||||||
|
|
||||||
@app.get("/{path:path}", include_in_schema=False)
|
try:
|
||||||
async def redirect_get(request: Request, path: str) -> Response:
|
async with httpx.AsyncClient() as client:
|
||||||
return redirect(request, path)
|
if request.method == "GET":
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
elif request.method == "POST":
|
||||||
@app.post("/{path:path}", include_in_schema=False)
|
response = await client.post(url, headers=headers, content=data)
|
||||||
async def redirect_post(request: Request, path: str) -> Response:
|
elif request.method == "PUT":
|
||||||
return redirect(request, path)
|
response = await client.put(url, headers=headers, content=data)
|
||||||
|
elif request.method == "DELETE":
|
||||||
|
response = await client.delete(url, headers=headers, content=data)
|
||||||
@app.put("/{path:path}", include_in_schema=False)
|
except Exception as e:
|
||||||
async def redirect_put(request: Request, path: str) -> Response:
|
error_page = create_error_page(
|
||||||
return redirect(request, path)
|
status_code="404",
|
||||||
|
error_title="Page Not Found",
|
||||||
|
error_message=f"""<pre>
|
||||||
def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectResponse]:
|
EOSdash server not reachable: '{url}'
|
||||||
# Path is not for EOSdash
|
Did you start the EOSdash server
|
||||||
if not (path.startswith("eosdash") or path == ""):
|
or set 'startup_eosdash'?
|
||||||
host = config_eos.server.eosdash_host
|
If there is no application server intended please
|
||||||
if host is None:
|
set 'eosdash_host' or 'eosdash_port' to None.
|
||||||
host = config_eos.server.host
|
|
||||||
host = str(host)
|
|
||||||
port = config_eos.server.eosdash_port
|
|
||||||
if port is None:
|
|
||||||
port = 8504
|
|
||||||
# Make hostname Windows friendly
|
|
||||||
if host == "0.0.0.0" and os.name == "nt":
|
|
||||||
host = "localhost"
|
|
||||||
url = f"http://{host}:{port}/"
|
|
||||||
error_page = create_error_page(
|
|
||||||
status_code="404",
|
|
||||||
error_title="Page Not Found",
|
|
||||||
error_message=f"""<pre>
|
|
||||||
URL is unknown: '{request.url}'
|
|
||||||
Did you want to connect to <a href="{url}" class="back-button">EOSdash</a>?
|
|
||||||
</pre>
|
</pre>
|
||||||
""",
|
""",
|
||||||
error_details="Unknown URL",
|
error_details=f"{e}",
|
||||||
|
)
|
||||||
|
return HTMLResponse(content=error_page, status_code=404)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
)
|
)
|
||||||
return HTMLResponse(content=error_page, status_code=404)
|
else:
|
||||||
|
# Redirect the root URL to the site map
|
||||||
# Make hostname Windows friendly
|
return RedirectResponse(url="/docs")
|
||||||
host = str(config_eos.server.eosdash_host)
|
|
||||||
if host == "0.0.0.0" and os.name == "nt":
|
|
||||||
host = "localhost"
|
|
||||||
if host and config_eos.server.eosdash_port:
|
|
||||||
# Redirect to EOSdash server
|
|
||||||
url = f"http://{host}:{config_eos.server.eosdash_port}/{path}"
|
|
||||||
return RedirectResponse(url=url, status_code=303)
|
|
||||||
|
|
||||||
# Redirect the root URL to the site map
|
|
||||||
return RedirectResponse(url="/docs", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
def run_eos(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None:
|
def run_eos(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None:
|
||||||
@@ -1246,10 +950,6 @@ def run_eos(host: str, port: int, log_level: str, access_log: bool, reload: bool
|
|||||||
# Make hostname Windows friendly
|
# Make hostname Windows friendly
|
||||||
if host == "0.0.0.0" and os.name == "nt":
|
if host == "0.0.0.0" and os.name == "nt":
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
|
|
||||||
# Wait for EOS port to be free - e.g. in case of restart
|
|
||||||
wait_for_port_free(port, timeout=120, waiting_app_name="EOS")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"akkudoktoreos.server.eos:app",
|
"akkudoktoreos.server.eos:app",
|
||||||
@@ -1317,11 +1017,8 @@ def main() -> None:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
host = args.host if args.host else get_default_host()
|
|
||||||
port = args.port if args.port else 8503
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_eos(host, port, args.log_level, args.access_log, args.reload)
|
run_eos(args.host, args.port, args.log_level, args.access_log, args.reload)
|
||||||
except:
|
except:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
@@ -1,165 +1,127 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
from functools import reduce
|
||||||
from pathlib import Path
|
from typing import Any, Union
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fasthtml.common import FileResponse, JSONResponse
|
from fasthtml.common import H1, Table, Td, Th, Thead, Titled, Tr, fast_app
|
||||||
from monsterui.core import FastHTML, Theme
|
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
||||||
|
from pydantic_core import PydanticUndefined
|
||||||
|
|
||||||
from akkudoktoreos.config.config import get_config
|
from akkudoktoreos.config.config import get_config
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.server.dash.bokeh import BokehJS
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
from akkudoktoreos.server.dash.components import Page
|
|
||||||
|
|
||||||
# Pages
|
|
||||||
from akkudoktoreos.server.dash.configuration import Configuration
|
|
||||||
from akkudoktoreos.server.dash.demo import Demo
|
|
||||||
from akkudoktoreos.server.dash.footer import Footer
|
|
||||||
from akkudoktoreos.server.dash.hello import Hello
|
|
||||||
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
|
|
||||||
|
|
||||||
# from akkudoktoreos.server.dash.altair import AltairJS
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
config_eos = get_config()
|
config_eos = get_config()
|
||||||
|
|
||||||
# The favicon for EOSdash
|
|
||||||
favicon_filepath = Path(__file__).parent.joinpath("dash/assets/favicon/favicon.ico")
|
|
||||||
if not favicon_filepath.exists():
|
|
||||||
raise ValueError(f"Does not exist {favicon_filepath}")
|
|
||||||
|
|
||||||
# Command line arguments
|
# Command line arguments
|
||||||
args: Optional[argparse.Namespace] = None
|
args = None
|
||||||
|
|
||||||
|
|
||||||
# Get frankenui and tailwind headers via CDN using Theme.green.headers()
|
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
|
||||||
# Add altair headers
|
default_value = ""
|
||||||
# hdrs=(Theme.green.headers(highlightjs=True), AltairJS,)
|
if regular_field:
|
||||||
hdrs = (
|
if (val := field_info.default) is not PydanticUndefined:
|
||||||
Theme.green.headers(highlightjs=True),
|
default_value = val
|
||||||
BokehJS,
|
else:
|
||||||
)
|
default_value = "N/A"
|
||||||
|
return default_value
|
||||||
|
|
||||||
# The EOSdash application
|
|
||||||
app: FastHTML = FastHTML(
|
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
|
||||||
title="EOSdash",
|
resolved_types: list[tuple[Any, list[str]]] = []
|
||||||
hdrs=hdrs,
|
|
||||||
|
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 = []
|
||||||
|
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, rt = fast_app(
|
||||||
secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"),
|
secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def eos_server() -> tuple[str, int]:
|
def config_table() -> Table:
|
||||||
"""Retrieves the EOS server host and port configuration.
|
rows = [
|
||||||
|
Tr(
|
||||||
If `args` is provided, it uses the `eos_host` and `eos_port` from `args`.
|
Td(config["name"]),
|
||||||
Otherwise, it falls back to the values from `config_eos.server`.
|
Td(config["value"]),
|
||||||
|
Td(config["default"]),
|
||||||
Returns:
|
Td(config["description"]),
|
||||||
tuple[str, int]: A tuple containing:
|
cls="even:bg-purple/5",
|
||||||
- `eos_host` (str): The EOS server hostname or IP.
|
)
|
||||||
- `eos_port` (int): The EOS server port.
|
for config in configs
|
||||||
"""
|
]
|
||||||
if args is None:
|
flds = "Name", "Value", "Default", "Description"
|
||||||
eos_host = str(config_eos.server.host)
|
head = Thead(*map(Th, flds), cls="bg-purple/10")
|
||||||
eos_port = config_eos.server.port
|
return Table(head, *rows, cls="w-full")
|
||||||
else:
|
|
||||||
eos_host = args.eos_host
|
|
||||||
eos_port = args.eos_port
|
|
||||||
eos_host = eos_host if eos_host else get_default_host()
|
|
||||||
eos_port = eos_port if eos_port else 8503
|
|
||||||
|
|
||||||
return eos_host, eos_port
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.ico")
|
@rt("/")
|
||||||
def get_eosdash_favicon(): # type: ignore
|
def get(): # type: ignore
|
||||||
"""Get favicon."""
|
return Titled("EOS Dashboard", H1("Configuration"), config_table())
|
||||||
return FileResponse(path=favicon_filepath)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None:
|
||||||
def get_eosdash(): # type: ignore
|
|
||||||
"""Serves the main EOSdash page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Page: The main dashboard page with navigation links and footer.
|
|
||||||
"""
|
|
||||||
return Page(
|
|
||||||
None,
|
|
||||||
{
|
|
||||||
"EOSdash": "/eosdash/hello",
|
|
||||||
"Config": "/eosdash/configuration",
|
|
||||||
"Demo": "/eosdash/demo",
|
|
||||||
},
|
|
||||||
Hello(),
|
|
||||||
Footer(*eos_server()),
|
|
||||||
"/eosdash/footer",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/eosdash/footer")
|
|
||||||
def get_eosdash_footer(): # type: ignore
|
|
||||||
"""Serves the EOSdash Foooter information.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Footer: The Footer component.
|
|
||||||
"""
|
|
||||||
return Footer(*eos_server())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/eosdash/hello")
|
|
||||||
def get_eosdash_hello(): # type: ignore
|
|
||||||
"""Serves the EOSdash Hello page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Hello: The Hello page component.
|
|
||||||
"""
|
|
||||||
return Hello()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/eosdash/configuration")
|
|
||||||
def get_eosdash_configuration(): # type: ignore
|
|
||||||
"""Serves the EOSdash Configuration page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configuration: The Configuration page component.
|
|
||||||
"""
|
|
||||||
return Configuration(*eos_server())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/eosdash/demo")
|
|
||||||
def get_eosdash_demo(): # type: ignore
|
|
||||||
"""Serves the EOSdash Demo page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Demo: The Demo page component.
|
|
||||||
"""
|
|
||||||
return Demo(*eos_server())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/eosdash/health")
|
|
||||||
def get_eosdash_health(): # type: ignore
|
|
||||||
"""Health check endpoint to verify that the EOSdash server is alive."""
|
|
||||||
return JSONResponse(
|
|
||||||
{
|
|
||||||
"status": "alive",
|
|
||||||
"pid": psutil.Process().pid,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/eosdash/assets/{fname:path}.{ext:static}")
|
|
||||||
def get_eosdash_assets(fname: str, ext: str): # type: ignore
|
|
||||||
"""Get assets."""
|
|
||||||
asset_filepath = Path(__file__).parent.joinpath(f"dash/assets/{fname}.{ext}")
|
|
||||||
return FileResponse(path=asset_filepath)
|
|
||||||
|
|
||||||
|
|
||||||
def run_eosdash() -> None:
|
|
||||||
"""Run the EOSdash server with the specified configurations.
|
"""Run the EOSdash server with the specified configurations.
|
||||||
|
|
||||||
This function starts the EOSdash server using the Uvicorn ASGI server. It accepts
|
This function starts the EOSdash server using the Uvicorn ASGI server. It accepts
|
||||||
@@ -169,77 +131,31 @@ def run_eosdash() -> None:
|
|||||||
server to the specified host and port, an error message is logged and the
|
server to the specified host and port, an error message is logged and the
|
||||||
application exits.
|
application exits.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
host (str): The hostname to bind the server to.
|
||||||
|
port (int): The port number to bind the server to.
|
||||||
|
log_level (str): The log level for the server. Options include "critical", "error",
|
||||||
|
"warning", "info", "debug", and "trace".
|
||||||
|
access_log (bool): Whether to enable or disable the access log. Set to True to enable.
|
||||||
|
reload (bool): Whether to enable or disable auto-reload. Set to True for development.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None
|
None
|
||||||
"""
|
"""
|
||||||
# Setup parameters from args, config_eos and default
|
|
||||||
# Remember parameters that are also in config
|
|
||||||
# - EOS host
|
|
||||||
if args and args.eos_host:
|
|
||||||
eos_host = args.eos_host
|
|
||||||
elif config_eos.server.host:
|
|
||||||
eos_host = config_eos.server.host
|
|
||||||
else:
|
|
||||||
eos_host = get_default_host()
|
|
||||||
config_eos.server.host = eos_host
|
|
||||||
# - EOS port
|
|
||||||
if args and args.eos_port:
|
|
||||||
eos_port = args.eos_port
|
|
||||||
elif config_eos.server.port:
|
|
||||||
eos_port = config_eos.server.port
|
|
||||||
else:
|
|
||||||
eos_port = 8503
|
|
||||||
config_eos.server.port = eos_port
|
|
||||||
# - EOSdash host
|
|
||||||
if args and args.host:
|
|
||||||
eosdash_host = args.host
|
|
||||||
elif config_eos.server.eosdash.host:
|
|
||||||
eosdash_host = config_eos.server.eosdash_host
|
|
||||||
else:
|
|
||||||
eosdash_host = get_default_host()
|
|
||||||
config_eos.server.eosdash_host = eosdash_host
|
|
||||||
# - EOS port
|
|
||||||
if args and args.port:
|
|
||||||
eosdash_port = args.port
|
|
||||||
elif config_eos.server.eosdash_port:
|
|
||||||
eosdash_port = config_eos.server.eosdash_port
|
|
||||||
else:
|
|
||||||
eosdash_port = 8504
|
|
||||||
config_eos.server.eosdash_port = eosdash_port
|
|
||||||
# - log level
|
|
||||||
if args and args.log_level:
|
|
||||||
log_level = args.log_level
|
|
||||||
else:
|
|
||||||
log_level = "info"
|
|
||||||
# - access log
|
|
||||||
if args and args.access_log:
|
|
||||||
access_log = args.access_log
|
|
||||||
else:
|
|
||||||
access_log = False
|
|
||||||
# - reload
|
|
||||||
if args and args.reload:
|
|
||||||
reload = args.reload
|
|
||||||
else:
|
|
||||||
reload = False
|
|
||||||
|
|
||||||
# Make hostname Windows friendly
|
# Make hostname Windows friendly
|
||||||
if eosdash_host == "0.0.0.0" and os.name == "nt":
|
if host == "0.0.0.0" and os.name == "nt":
|
||||||
eosdash_host = "localhost"
|
host = "localhost"
|
||||||
|
|
||||||
# Wait for EOSdash port to be free - e.g. in case of restart
|
|
||||||
wait_for_port_free(eosdash_port, timeout=120, waiting_app_name="EOSdash")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"akkudoktoreos.server.eosdash:app",
|
"akkudoktoreos.server.eosdash:app",
|
||||||
host=eosdash_host,
|
host=host,
|
||||||
port=eosdash_port,
|
port=port,
|
||||||
log_level=log_level.lower(),
|
log_level=log_level.lower(), # Convert log_level to lowercase
|
||||||
access_log=access_log,
|
access_log=access_log,
|
||||||
reload=reload,
|
reload=reload,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Could not bind to host {eosdash_host}:{eosdash_port}. Error: {e}")
|
logger.error(f"Could not bind to host {host}:{port}. Error: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
@@ -248,7 +164,7 @@ def main() -> None:
|
|||||||
|
|
||||||
This function sets up the argument parser to accept command-line arguments for
|
This function sets up the argument parser to accept command-line arguments for
|
||||||
host, port, log_level, access_log, and reload. It uses default values from the
|
host, port, log_level, access_log, and reload. It uses default values from the
|
||||||
config module if arguments are not provided. After parsing the arguments,
|
config_eos module if arguments are not provided. After parsing the arguments,
|
||||||
it starts the EOSdash server with the specified configurations.
|
it starts the EOSdash server with the specified configurations.
|
||||||
|
|
||||||
Command-line Arguments:
|
Command-line Arguments:
|
||||||
@@ -262,6 +178,7 @@ def main() -> None:
|
|||||||
"""
|
"""
|
||||||
parser = argparse.ArgumentParser(description="Start EOSdash server.")
|
parser = argparse.ArgumentParser(description="Start EOSdash server.")
|
||||||
|
|
||||||
|
# Host and port arguments with defaults from config_eos
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--host",
|
"--host",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -274,18 +191,22 @@ def main() -> None:
|
|||||||
default=config_eos.server.eosdash_port,
|
default=config_eos.server.eosdash_port,
|
||||||
help="Port for the EOSdash server (default: value from config)",
|
help="Port for the EOSdash server (default: value from config)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# EOS Host and port arguments with defaults from config_eos
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--eos-host",
|
"--eos-host",
|
||||||
type=str,
|
type=str,
|
||||||
default=str(config_eos.server.host),
|
default=str(config_eos.server.host),
|
||||||
help="Host of the EOS server (default: value from config)",
|
help="Host for the EOS server (default: value from config)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--eos-port",
|
"--eos-port",
|
||||||
type=int,
|
type=int,
|
||||||
default=config_eos.server.port,
|
default=config_eos.server.port,
|
||||||
help="Port of the EOS server (default: value from config)",
|
help="Port for the EOS server (default: value from config)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Optional arguments for log_level, access_log, and reload
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--log_level",
|
"--log_level",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -296,7 +217,7 @@ def main() -> None:
|
|||||||
"--access_log",
|
"--access_log",
|
||||||
type=bool,
|
type=bool,
|
||||||
default=False,
|
default=False,
|
||||||
help="Enable or disable access log. Options: True or False (default: False)",
|
help="Enable or disable access log. Options: True or False (default: True)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--reload",
|
"--reload",
|
||||||
@@ -305,15 +226,11 @@ def main() -> None:
|
|||||||
help="Enable or disable auto-reload. Useful for development. Options: True or False (default: False)",
|
help="Enable or disable auto-reload. Useful for development. Options: True or False (default: False)",
|
||||||
)
|
)
|
||||||
|
|
||||||
global args
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_eosdash()
|
run_eosdash(args.host, args.port, args.log_level, args.access_log, args.reload)
|
||||||
except Exception as ex:
|
except:
|
||||||
error_msg = f"Failed to run EOSdash: {ex}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,91 +0,0 @@
|
|||||||
ERROR_PAGE_TEMPLATE = """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Energy Optimization System (EOS) Error</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.error-container {
|
|
||||||
background: white;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.error-code {
|
|
||||||
font-size: 4rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #e53e3e;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.error-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: #2d3748;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
.error-message {
|
|
||||||
color: #4a5568;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.error-details {
|
|
||||||
background: #f7fafc;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
font-family: monospace;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.back-button {
|
|
||||||
background: #3182ce;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
.back-button:hover {
|
|
||||||
background: #2c5282;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="error-container">
|
|
||||||
<h1 class="error-code">STATUS_CODE</h1>
|
|
||||||
<h2 class="error-title">ERROR_TITLE</h2>
|
|
||||||
<p class="error-message">ERROR_MESSAGE</p>
|
|
||||||
<div class="error-details">ERROR_DETAILS</div>
|
|
||||||
<a href="/docs" class="back-button">Back to Home</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def create_error_page(
|
|
||||||
status_code: str, error_title: str, error_message: str, error_details: str
|
|
||||||
) -> str:
|
|
||||||
"""Create an error page by replacing placeholders in the template."""
|
|
||||||
return (
|
|
||||||
ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code)
|
|
||||||
.replace("ERROR_TITLE", error_title)
|
|
||||||
.replace("ERROR_MESSAGE", error_message)
|
|
||||||
.replace("ERROR_DETAILS", error_details)
|
|
||||||
)
|
|
@@ -1,92 +0,0 @@
|
|||||||
"""Task handling taken from fastapi-utils/fastapi_utils/tasks.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Callable, Coroutine, Union
|
|
||||||
|
|
||||||
from starlette.concurrency import run_in_threadpool
|
|
||||||
|
|
||||||
NoArgsNoReturnFuncT = Callable[[], None]
|
|
||||||
NoArgsNoReturnAsyncFuncT = Callable[[], Coroutine[Any, Any, None]]
|
|
||||||
ExcArgNoReturnFuncT = Callable[[Exception], None]
|
|
||||||
ExcArgNoReturnAsyncFuncT = Callable[[Exception], Coroutine[Any, Any, None]]
|
|
||||||
NoArgsNoReturnAnyFuncT = Union[NoArgsNoReturnFuncT, NoArgsNoReturnAsyncFuncT]
|
|
||||||
ExcArgNoReturnAnyFuncT = Union[ExcArgNoReturnFuncT, ExcArgNoReturnAsyncFuncT]
|
|
||||||
NoArgsNoReturnDecorator = Callable[[NoArgsNoReturnAnyFuncT], NoArgsNoReturnAsyncFuncT]
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_func(func: NoArgsNoReturnAnyFuncT) -> None:
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
|
||||||
await func()
|
|
||||||
else:
|
|
||||||
await run_in_threadpool(func)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_exc(exc: Exception, on_exception: ExcArgNoReturnAnyFuncT | None) -> None:
|
|
||||||
if on_exception:
|
|
||||||
if asyncio.iscoroutinefunction(on_exception):
|
|
||||||
await on_exception(exc)
|
|
||||||
else:
|
|
||||||
await run_in_threadpool(on_exception, exc)
|
|
||||||
|
|
||||||
|
|
||||||
def repeat_every(
|
|
||||||
*,
|
|
||||||
seconds: float,
|
|
||||||
wait_first: float | None = None,
|
|
||||||
logger: logging.Logger | None = None,
|
|
||||||
raise_exceptions: bool = False,
|
|
||||||
max_repetitions: int | None = None,
|
|
||||||
on_complete: NoArgsNoReturnAnyFuncT | None = None,
|
|
||||||
on_exception: ExcArgNoReturnAnyFuncT | None = None,
|
|
||||||
) -> NoArgsNoReturnDecorator:
|
|
||||||
"""A decorator that modifies a function so it is periodically re-executed after its first call.
|
|
||||||
|
|
||||||
The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished
|
|
||||||
by using `functools.partial` or otherwise wrapping the target function prior to decoration.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
seconds: float
|
|
||||||
The number of seconds to wait between repeated calls
|
|
||||||
wait_first: float (default None)
|
|
||||||
If not None, the function will wait for the given duration before the first call
|
|
||||||
max_repetitions: Optional[int] (default None)
|
|
||||||
The maximum number of times to call the repeated function. If `None`, the function is repeated forever.
|
|
||||||
on_complete: Optional[Callable[[], None]] (default None)
|
|
||||||
A function to call after the final repetition of the decorated function.
|
|
||||||
on_exception: Optional[Callable[[Exception], None]] (default None)
|
|
||||||
A function to call when an exception is raised by the decorated function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func: NoArgsNoReturnAnyFuncT) -> NoArgsNoReturnAsyncFuncT:
|
|
||||||
"""Converts the decorated function into a repeated, periodically-called version."""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapped() -> None:
|
|
||||||
async def loop() -> None:
|
|
||||||
if wait_first is not None:
|
|
||||||
await asyncio.sleep(wait_first)
|
|
||||||
|
|
||||||
repetitions = 0
|
|
||||||
while max_repetitions is None or repetitions < max_repetitions:
|
|
||||||
try:
|
|
||||||
await _handle_func(func)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
await _handle_exc(exc, on_exception)
|
|
||||||
|
|
||||||
repetitions += 1
|
|
||||||
await asyncio.sleep(seconds)
|
|
||||||
|
|
||||||
if on_complete:
|
|
||||||
await _handle_func(on_complete)
|
|
||||||
|
|
||||||
asyncio.ensure_future(loop())
|
|
||||||
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
return decorator
|
|
@@ -1,10 +1,7 @@
|
|||||||
"""Server Module."""
|
"""Server Module."""
|
||||||
|
|
||||||
import os
|
from typing import Optional
|
||||||
import time
|
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
from pydantic import Field, IPvAnyAddress, field_validator
|
from pydantic import Field, IPvAnyAddress, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
@@ -13,107 +10,24 @@ from akkudoktoreos.core.logging import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_default_host() -> str:
|
|
||||||
if os.name == "nt":
|
|
||||||
return "127.0.0.1"
|
|
||||||
return "0.0.0.0"
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_port_free(port: int, timeout: int = 0, waiting_app_name: str = "App") -> bool:
|
|
||||||
"""Wait for a network port to become free, with timeout.
|
|
||||||
|
|
||||||
Checks if the port is currently in use and logs warnings with process details.
|
|
||||||
Retries every 3 seconds until timeout is reached.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port: The network port number to check
|
|
||||||
timeout: Maximum seconds to wait (0 means check once without waiting)
|
|
||||||
waiting_app_name: Name of the application waiting for the port
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if port is free, False if port is still in use after timeout
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If port number or timeout is invalid
|
|
||||||
psutil.Error: If there are problems accessing process information
|
|
||||||
"""
|
|
||||||
if not 0 <= port <= 65535:
|
|
||||||
raise ValueError(f"Invalid port number: {port}")
|
|
||||||
if timeout < 0:
|
|
||||||
raise ValueError(f"Invalid timeout: {timeout}")
|
|
||||||
|
|
||||||
def get_processes_using_port() -> list[dict]:
|
|
||||||
"""Get info about processes using the specified port."""
|
|
||||||
processes: list[dict] = []
|
|
||||||
seen_pids: set[int] = set()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for conn in psutil.net_connections(kind="inet"):
|
|
||||||
if conn.laddr.port == port and conn.pid not in seen_pids:
|
|
||||||
try:
|
|
||||||
process = psutil.Process(conn.pid)
|
|
||||||
seen_pids.add(conn.pid)
|
|
||||||
processes.append(process.as_dict(attrs=["pid", "cmdline"]))
|
|
||||||
except psutil.NoSuchProcess:
|
|
||||||
continue
|
|
||||||
except psutil.Error as e:
|
|
||||||
logger.error(f"Error checking port {port}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
return processes
|
|
||||||
|
|
||||||
retries = max(int(timeout / 3), 1) if timeout > 0 else 1
|
|
||||||
|
|
||||||
for _ in range(retries):
|
|
||||||
process_info = get_processes_using_port()
|
|
||||||
|
|
||||||
if not process_info:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if timeout <= 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.info(f"{waiting_app_name} waiting for port {port} to become free...")
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
if process_info:
|
|
||||||
logger.warning(
|
|
||||||
f"{waiting_app_name} port {port} still in use after waiting {timeout} seconds."
|
|
||||||
)
|
|
||||||
for info in process_info:
|
|
||||||
logger.warning(
|
|
||||||
f"Process using port - PID: {info['pid']}, Command: {' '.join(info['cmdline'])}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class ServerCommonSettings(SettingsBaseModel):
|
class ServerCommonSettings(SettingsBaseModel):
|
||||||
"""Server Configuration."""
|
"""Server Configuration.
|
||||||
|
|
||||||
host: Optional[IPvAnyAddress] = Field(
|
Attributes:
|
||||||
default=get_default_host(), description="EOS server IP address."
|
To be added
|
||||||
)
|
"""
|
||||||
|
|
||||||
|
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.")
|
port: Optional[int] = Field(default=8503, description="EOS server IP port number.")
|
||||||
verbose: Optional[bool] = Field(default=False, description="Enable debug output")
|
verbose: Optional[bool] = Field(default=False, description="Enable debug output")
|
||||||
startup_eosdash: Optional[bool] = Field(
|
startup_eosdash: Optional[bool] = Field(
|
||||||
default=True, description="EOS server to start EOSdash server."
|
default=True, description="EOS server to start EOSdash server."
|
||||||
)
|
)
|
||||||
eosdash_host: Optional[IPvAnyAddress] = Field(
|
eosdash_host: Optional[IPvAnyAddress] = Field(
|
||||||
default=get_default_host(), description="EOSdash server IP address."
|
default="0.0.0.0", description="EOSdash server IP address."
|
||||||
)
|
)
|
||||||
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("host", "eosdash_host", mode="before")
|
|
||||||
def validate_server_host(
|
|
||||||
cls, value: Optional[Union[str, IPvAnyAddress]]
|
|
||||||
) -> Optional[Union[str, IPvAnyAddress]]:
|
|
||||||
if isinstance(value, str):
|
|
||||||
if value.lower() in ("localhost", "loopback"):
|
|
||||||
value = "127.0.0.1"
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("port", "eosdash_port")
|
@field_validator("port", "eosdash_port")
|
||||||
def validate_server_port(cls, value: Optional[int]) -> Optional[int]:
|
def validate_server_port(cls, value: Optional[int]) -> Optional[int]:
|
||||||
if value is not None and not (1024 <= value <= 49151):
|
if value is not None and not (1024 <= value <= 49151):
|
||||||
|
@@ -1,14 +1,32 @@
|
|||||||
"""In-memory and file caching.
|
"""Class for in-memory managing of cache files.
|
||||||
|
|
||||||
Decorators and classes for caching results of computations,
|
The `CacheFileStore` class is a singleton-based, thread-safe key-value store for managing
|
||||||
both in memory (using an LRU cache) and in temporary files. It also includes
|
temporary file objects, allowing the creation, retrieval, and management of cache files.
|
||||||
mechanisms for managing cache file expiration and retrieval.
|
|
||||||
|
Classes:
|
||||||
|
--------
|
||||||
|
- CacheFileStore: A thread-safe, singleton class for in-memory managing of file-like cache objects.
|
||||||
|
- CacheFileStoreMeta: Metaclass for enforcing the singleton behavior in `CacheFileStore`.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
--------------
|
||||||
|
# CacheFileStore usage
|
||||||
|
>>> cache_store = CacheFileStore()
|
||||||
|
>>> cache_store.create('example_key')
|
||||||
|
>>> cache_file = cache_store.get('example_key')
|
||||||
|
>>> cache_file.write('Some data')
|
||||||
|
>>> cache_file.seek(0)
|
||||||
|
>>> print(cache_file.read()) # Output: 'Some data'
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
------
|
||||||
|
- Cache files are automatically associated with the current date unless specified.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import functools
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -17,8 +35,8 @@ from typing import (
|
|||||||
IO,
|
IO,
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
ClassVar,
|
|
||||||
Dict,
|
Dict,
|
||||||
|
Generic,
|
||||||
List,
|
List,
|
||||||
Literal,
|
Literal,
|
||||||
Optional,
|
Optional,
|
||||||
@@ -26,226 +44,29 @@ from typing import (
|
|||||||
TypeVar,
|
TypeVar,
|
||||||
)
|
)
|
||||||
|
|
||||||
import cachebox
|
|
||||||
from pendulum import DateTime, Duration
|
from pendulum import DateTime, Duration
|
||||||
from pydantic import Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin
|
from akkudoktoreos.core.coreabc import ConfigMixin
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
|
||||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------
|
T = TypeVar("T")
|
||||||
# In-Memory Caching Functionality
|
|
||||||
# ---------------------------------
|
|
||||||
|
|
||||||
# Define a type variable for methods and functions
|
|
||||||
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
|
|
||||||
|
|
||||||
|
|
||||||
def cache_until_update_store_callback(event: int, key: Any, value: Any) -> None:
|
|
||||||
"""Calback function for CacheUntilUpdateStore."""
|
|
||||||
CacheUntilUpdateStore.last_event = event
|
|
||||||
CacheUntilUpdateStore.last_key = key
|
|
||||||
CacheUntilUpdateStore.last_value = value
|
|
||||||
if event == cachebox.EVENT_MISS:
|
|
||||||
CacheUntilUpdateStore.miss_count += 1
|
|
||||||
elif event == cachebox.EVENT_HIT:
|
|
||||||
CacheUntilUpdateStore.hit_count += 1
|
|
||||||
else:
|
|
||||||
# unreachable code
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class CacheUntilUpdateStore(SingletonMixin):
|
|
||||||
"""Singleton-based in-memory LRU (Least Recently Used) cache.
|
|
||||||
|
|
||||||
This cache is shared across the application to store results of decorated
|
|
||||||
methods or functions until the next EMS (Energy Management System) update.
|
|
||||||
|
|
||||||
The cache uses an LRU eviction strategy, storing up to 100 items, with the oldest
|
|
||||||
items being evicted once the cache reaches its capacity.
|
|
||||||
"""
|
|
||||||
|
|
||||||
cache: ClassVar[cachebox.LRUCache] = cachebox.LRUCache(maxsize=100, iterable=None, capacity=100)
|
|
||||||
last_event: ClassVar[Optional[int]] = None
|
|
||||||
last_key: ClassVar[Any] = None
|
|
||||||
last_value: ClassVar[Any] = None
|
|
||||||
hit_count: ClassVar[int] = 0
|
|
||||||
miss_count: ClassVar[int] = 0
|
|
||||||
|
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
||||||
"""Initializes the `CacheUntilUpdateStore` instance with default parameters.
|
|
||||||
|
|
||||||
The cache uses an LRU eviction strategy with a maximum size of 100 items.
|
|
||||||
This cache is a singleton, meaning only one instance will exist throughout
|
|
||||||
the application lifecycle.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> cache = CacheUntilUpdateStore()
|
|
||||||
"""
|
|
||||||
if hasattr(self, "_initialized"):
|
|
||||||
return
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
|
||||||
"""Propagates method calls to the cache object.
|
|
||||||
|
|
||||||
This method allows you to call methods on the underlying cache object,
|
|
||||||
and it will delegate the call to the cache's corresponding method.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): The name of the method being called.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Callable: A method bound to the cache object.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AttributeError: If the cache object does not have the requested method.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> result = cache.get("key")
|
|
||||||
"""
|
|
||||||
# This will return a method of the target cache, or raise an AttributeError
|
|
||||||
target_attr = getattr(self.cache, name)
|
|
||||||
if callable(target_attr):
|
|
||||||
return target_attr
|
|
||||||
else:
|
|
||||||
return target_attr
|
|
||||||
|
|
||||||
def __getitem__(self, key: Any) -> Any:
|
|
||||||
"""Retrieves an item from the cache by its key.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key (Any): The key used for subscripting to retrieve an item.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Any: The value corresponding to the key in the cache.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If the key does not exist in the cache.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> value = cache["user_data"]
|
|
||||||
"""
|
|
||||||
return CacheUntilUpdateStore.cache[key]
|
|
||||||
|
|
||||||
def __setitem__(self, key: Any, value: Any) -> None:
|
|
||||||
"""Stores an item in the cache.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key (Any): The key used to store the item in the cache.
|
|
||||||
value (Any): The value to store.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> cache["user_data"] = {"name": "Alice", "age": 30}
|
|
||||||
"""
|
|
||||||
CacheUntilUpdateStore.cache[key] = value
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
"""Returns the number of items in the cache."""
|
|
||||||
return len(CacheUntilUpdateStore.cache)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
"""Provides a string representation of the CacheUntilUpdateStore object."""
|
|
||||||
return repr(CacheUntilUpdateStore.cache)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Clears the cache, removing all stored items.
|
|
||||||
|
|
||||||
This method propagates the `clear` method call to the underlying cache object,
|
|
||||||
ensuring that the cache is emptied when necessary (e.g., at the end of the energy
|
|
||||||
management system run).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> cache.clear()
|
|
||||||
"""
|
|
||||||
if hasattr(self.cache, "clear") and callable(getattr(self.cache, "clear")):
|
|
||||||
CacheUntilUpdateStore.cache.clear()
|
|
||||||
CacheUntilUpdateStore.last_event = None
|
|
||||||
CacheUntilUpdateStore.last_key = None
|
|
||||||
CacheUntilUpdateStore.last_value = None
|
|
||||||
CacheUntilUpdateStore.miss_count = 0
|
|
||||||
CacheUntilUpdateStore.hit_count = 0
|
|
||||||
else:
|
|
||||||
raise AttributeError(f"'{self.cache.__class__.__name__}' object has no method 'clear'")
|
|
||||||
|
|
||||||
|
|
||||||
def cachemethod_until_update(method: TCallable) -> TCallable:
|
|
||||||
"""Decorator for in memory caching the result of an instance method.
|
|
||||||
|
|
||||||
This decorator caches the method's result in `CacheUntilUpdateStore`, ensuring
|
|
||||||
that subsequent calls with the same arguments return the cached result until the
|
|
||||||
next EMS update cycle.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
method (Callable): The instance method to be decorated.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Callable: The wrapped method with caching functionality.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> class MyClass:
|
|
||||||
>>> @cachemethod_until_update
|
|
||||||
>>> def expensive_method(self, param: str) -> str:
|
|
||||||
>>> # Perform expensive computation
|
|
||||||
>>> return f"Computed {param}"
|
|
||||||
"""
|
|
||||||
|
|
||||||
@cachebox.cachedmethod(
|
|
||||||
cache=CacheUntilUpdateStore().cache, callback=cache_until_update_store_callback
|
|
||||||
)
|
|
||||||
@functools.wraps(method)
|
|
||||||
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
||||||
result = method(self, *args, **kwargs)
|
|
||||||
return result
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def cache_until_update(func: TCallable) -> TCallable:
|
|
||||||
"""Decorator for in memory caching the result of a standalone function.
|
|
||||||
|
|
||||||
This decorator caches the function's result in `CacheUntilUpdateStore`, ensuring
|
|
||||||
that subsequent calls with the same arguments return the cached result until the
|
|
||||||
next EMS update cycle.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
func (Callable): The function to be decorated.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Callable: The wrapped function with caching functionality.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> @cache_until_next_update
|
|
||||||
>>> def expensive_function(param: str) -> str:
|
|
||||||
>>> # Perform expensive computation
|
|
||||||
>>> return f"Computed {param}"
|
|
||||||
"""
|
|
||||||
|
|
||||||
@cachebox.cached(
|
|
||||||
cache=CacheUntilUpdateStore().cache, callback=cache_until_update_store_callback
|
|
||||||
)
|
|
||||||
@functools.wraps(func)
|
|
||||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
return result
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------
|
|
||||||
# Cache File Management
|
|
||||||
# ---------------------------------
|
|
||||||
|
|
||||||
Param = ParamSpec("Param")
|
Param = ParamSpec("Param")
|
||||||
RetType = TypeVar("RetType")
|
RetType = TypeVar("RetType")
|
||||||
|
|
||||||
|
|
||||||
class CacheFileRecord(PydanticBaseModel):
|
class CacheFileRecord(BaseModel):
|
||||||
|
# Enable custom serialization globally in config
|
||||||
|
model_config = ConfigDict(
|
||||||
|
arbitrary_types_allowed=True,
|
||||||
|
use_enum_values=True,
|
||||||
|
validate_assignment=True,
|
||||||
|
)
|
||||||
|
|
||||||
cache_file: Any = Field(..., description="File descriptor of the cache file.")
|
cache_file: Any = Field(..., description="File descriptor of the cache file.")
|
||||||
until_datetime: DateTime = Field(..., description="Datetime until the cache file is valid.")
|
until_datetime: DateTime = Field(..., description="Datetime until the cache file is valid.")
|
||||||
ttl_duration: Optional[Duration] = Field(
|
ttl_duration: Optional[Duration] = Field(
|
||||||
@@ -253,7 +74,24 @@ class CacheFileRecord(PydanticBaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CacheFileStore(ConfigMixin, SingletonMixin):
|
class CacheFileStoreMeta(type, Generic[T]):
|
||||||
|
"""A thread-safe implementation of CacheFileStore."""
|
||||||
|
|
||||||
|
_instances: dict[CacheFileStoreMeta[T], T] = {}
|
||||||
|
|
||||||
|
_lock: threading.Lock = threading.Lock()
|
||||||
|
"""Lock object to synchronize threads on first access to CacheFileStore."""
|
||||||
|
|
||||||
|
def __call__(cls) -> T:
|
||||||
|
"""Return CacheFileStore instance."""
|
||||||
|
with cls._lock:
|
||||||
|
if cls not in cls._instances:
|
||||||
|
instance = super().__call__()
|
||||||
|
cls._instances[cls] = instance
|
||||||
|
return cls._instances[cls]
|
||||||
|
|
||||||
|
|
||||||
|
class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta):
|
||||||
"""A key-value store that manages file-like tempfile objects to be used as cache files.
|
"""A key-value store that manages file-like tempfile objects to be used as cache files.
|
||||||
|
|
||||||
Cache files are associated with a date. If no date is specified, the cache files are
|
Cache files are associated with a date. If no date is specified, the cache files are
|
||||||
@@ -267,7 +105,7 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
store (dict): A dictionary that holds the in-memory cache file objects
|
store (dict): A dictionary that holds the in-memory cache file objects
|
||||||
with their associated keys and dates.
|
with their associated keys and dates.
|
||||||
|
|
||||||
Example:
|
Example usage:
|
||||||
>>> cache_store = CacheFileStore()
|
>>> cache_store = CacheFileStore()
|
||||||
>>> cache_store.create('example_file')
|
>>> cache_store.create('example_file')
|
||||||
>>> cache_file = cache_store.get('example_file')
|
>>> cache_file = cache_store.get('example_file')
|
||||||
@@ -276,18 +114,14 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
>>> print(cache_file.read()) # Output: 'Some data'
|
>>> print(cache_file.read()) # Output: 'Some data'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initializes the CacheFileStore instance.
|
"""Initializes the CacheFileStore instance.
|
||||||
|
|
||||||
This constructor sets up an empty key-value store (a dictionary) where each key
|
This constructor sets up an empty key-value store (a dictionary) where each key
|
||||||
corresponds to a cache file that is associated with a given key and an optional date.
|
corresponds to a cache file that is associated with a given key and an optional date.
|
||||||
"""
|
"""
|
||||||
if hasattr(self, "_initialized"):
|
|
||||||
return
|
|
||||||
self._store: Dict[str, CacheFileRecord] = {}
|
self._store: Dict[str, CacheFileRecord] = {}
|
||||||
self._store_lock = threading.RLock()
|
self._store_lock = threading.Lock()
|
||||||
self._store_file = self.config.cache.path().joinpath("cachefilestore.json")
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def _until_datetime_by_options(
|
def _until_datetime_by_options(
|
||||||
self,
|
self,
|
||||||
@@ -495,9 +329,9 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
# File already available
|
# File already available
|
||||||
cache_file_obj = cache_item.cache_file
|
cache_file_obj = cache_item.cache_file
|
||||||
else:
|
else:
|
||||||
self.config.cache.path().mkdir(parents=True, exist_ok=True)
|
self.config.general.data_cache_path.mkdir(parents=True, exist_ok=True)
|
||||||
cache_file_obj = tempfile.NamedTemporaryFile(
|
cache_file_obj = tempfile.NamedTemporaryFile(
|
||||||
mode=mode, delete=delete, suffix=suffix, dir=self.config.cache.path()
|
mode=mode, delete=delete, suffix=suffix, dir=self.config.general.data_cache_path
|
||||||
)
|
)
|
||||||
self._store[cache_file_key] = CacheFileRecord(
|
self._store[cache_file_key] = CacheFileRecord(
|
||||||
cache_file=cache_file_obj,
|
cache_file=cache_file_obj,
|
||||||
@@ -668,7 +502,7 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
|
|
||||||
def clear(
|
def clear(
|
||||||
self,
|
self,
|
||||||
clear_all: Optional[bool] = None,
|
clear_all: bool = False,
|
||||||
before_datetime: Optional[Any] = None,
|
before_datetime: Optional[Any] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Deletes all cache files or those expiring before `before_datetime`.
|
"""Deletes all cache files or those expiring before `before_datetime`.
|
||||||
@@ -682,6 +516,8 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
Raises:
|
Raises:
|
||||||
OSError: If there's an error during file deletion.
|
OSError: If there's an error during file deletion.
|
||||||
"""
|
"""
|
||||||
|
delete_keys = [] # List of keys to delete, prevent deleting when traversing the store
|
||||||
|
|
||||||
# Some weired logic to prevent calling to_datetime on clear_all.
|
# Some weired logic to prevent calling to_datetime on clear_all.
|
||||||
# Clear_all may be set on __del__. At this time some info for to_datetime will
|
# Clear_all may be set on __del__. At this time some info for to_datetime will
|
||||||
# not be available anymore.
|
# not be available anymore.
|
||||||
@@ -692,8 +528,6 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
before_datetime = to_datetime(before_datetime)
|
before_datetime = to_datetime(before_datetime)
|
||||||
|
|
||||||
with self._store_lock: # Synchronize access to _store
|
with self._store_lock: # Synchronize access to _store
|
||||||
delete_keys = [] # List of keys to delete, prevent deleting when traversing the store
|
|
||||||
|
|
||||||
for cache_file_key, cache_item in self._store.items():
|
for cache_file_key, cache_item in self._store.items():
|
||||||
# Some weired logic to prevent calling to_datetime on clear_all.
|
# Some weired logic to prevent calling to_datetime on clear_all.
|
||||||
# Clear_all may be set on __del__. At this time some info for to_datetime will
|
# Clear_all may be set on __del__. At this time some info for to_datetime will
|
||||||
@@ -732,89 +566,6 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
for delete_key in delete_keys:
|
for delete_key in delete_keys:
|
||||||
del self._store[delete_key]
|
del self._store[delete_key]
|
||||||
|
|
||||||
def current_store(self) -> dict:
|
|
||||||
"""Current state of the store.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): current cache management data.
|
|
||||||
"""
|
|
||||||
with self._store_lock:
|
|
||||||
store_current = {}
|
|
||||||
for key, record in self._store.items():
|
|
||||||
ttl_duration = record.ttl_duration
|
|
||||||
if ttl_duration:
|
|
||||||
ttl_duration = ttl_duration.total_seconds()
|
|
||||||
store_current[key] = {
|
|
||||||
# Convert file-like objects to file paths for serialization
|
|
||||||
"cache_file": self._get_file_path(record.cache_file),
|
|
||||||
"mode": record.cache_file.mode,
|
|
||||||
"until_datetime": to_datetime(record.until_datetime, as_string=True),
|
|
||||||
"ttl_duration": ttl_duration,
|
|
||||||
}
|
|
||||||
return store_current
|
|
||||||
|
|
||||||
def save_store(self) -> dict:
|
|
||||||
"""Saves the current state of the store to a file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): cache management data that was saved.
|
|
||||||
"""
|
|
||||||
with self._store_lock:
|
|
||||||
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
store_to_save = self.current_store()
|
|
||||||
with self._store_file.open("w", encoding="utf-8", newline="\n") as f:
|
|
||||||
try:
|
|
||||||
json.dump(store_to_save, f, indent=4)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving cache file store: {e}")
|
|
||||||
return store_to_save
|
|
||||||
|
|
||||||
def load_store(self) -> dict:
|
|
||||||
"""Loads the state of the store from a file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
data (dict): cache management data that was loaded.
|
|
||||||
"""
|
|
||||||
with self._store_lock:
|
|
||||||
store_loaded = {}
|
|
||||||
if self._store_file.exists():
|
|
||||||
with self._store_file.open("r", encoding="utf-8", newline=None) as f:
|
|
||||||
try:
|
|
||||||
store_to_load = json.load(f)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error loading cache file store: {e}\n"
|
|
||||||
+ f"Deleting the store file {self._store_file}."
|
|
||||||
)
|
|
||||||
self._store_file.unlink()
|
|
||||||
return {}
|
|
||||||
for key, record in store_to_load.items():
|
|
||||||
if record is None:
|
|
||||||
continue
|
|
||||||
if key in self._store.keys():
|
|
||||||
# Already available - do not overwrite by record from file
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
cache_file_obj = open(
|
|
||||||
record["cache_file"], "rb+" if "b" in record["mode"] else "r+"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
cache_file_record = record["cache_file"]
|
|
||||||
logger.warning(f"Can not open cache file '{cache_file_record}': {e}")
|
|
||||||
continue
|
|
||||||
ttl_duration = record["ttl_duration"]
|
|
||||||
if ttl_duration:
|
|
||||||
ttl_duration = to_duration(float(record["ttl_duration"]))
|
|
||||||
self._store[key] = CacheFileRecord(
|
|
||||||
cache_file=cache_file_obj,
|
|
||||||
until_datetime=record["until_datetime"],
|
|
||||||
ttl_duration=ttl_duration,
|
|
||||||
)
|
|
||||||
cache_file_obj.seek(0)
|
|
||||||
# Remember newly loaded
|
|
||||||
store_loaded[key] = record
|
|
||||||
return store_loaded
|
|
||||||
|
|
||||||
|
|
||||||
def cache_in_file(
|
def cache_in_file(
|
||||||
ignore_params: List[str] = [],
|
ignore_params: List[str] = [],
|
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import textwrap
|
import textwrap
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
@@ -46,7 +47,7 @@ class VisualizationReport(ConfigMixin):
|
|||||||
"""Add a chart function to the current group and save it as a PNG and SVG."""
|
"""Add a chart function to the current group and save it as a PNG and SVG."""
|
||||||
self.current_group.append(chart_func)
|
self.current_group.append(chart_func)
|
||||||
if self.create_img and title:
|
if self.create_img and title:
|
||||||
server_output_dir = self.config.cache.path()
|
server_output_dir = self.config.general.data_cache_path
|
||||||
server_output_dir.mkdir(parents=True, exist_ok=True)
|
server_output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
fig, ax = plt.subplots()
|
fig, ax = plt.subplots()
|
||||||
chart_func()
|
chart_func()
|
||||||
@@ -614,7 +615,7 @@ def prepare_visualize(
|
|||||||
|
|
||||||
if filtered_balance.size > 0 or filtered_losses.size > 0:
|
if filtered_balance.size > 0 or filtered_losses.size > 0:
|
||||||
report.finalize_group()
|
report.finalize_group()
|
||||||
if logger.level == "DEBUG" or results["fixed_seed"]:
|
if logger.level == logging.DEBUG or results["fixed_seed"]:
|
||||||
report.create_line_chart(
|
report.create_line_chart(
|
||||||
0,
|
0,
|
||||||
[
|
[
|
||||||
@@ -710,9 +711,9 @@ def generate_example_report(filename: str = "example_report.pdf") -> None:
|
|||||||
|
|
||||||
report.finalize_group() # Finalize the third group of charts
|
report.finalize_group() # Finalize the third group of charts
|
||||||
|
|
||||||
logger.setLevel("DEBUG") # set level for example report
|
logger.setLevel(logging.DEBUG) # set level for example report
|
||||||
|
|
||||||
if logger.level == "DEBUG":
|
if logger.level == logging.DEBUG:
|
||||||
report.create_line_chart(
|
report.create_line_chart(
|
||||||
x_hours,
|
x_hours,
|
||||||
[np.array([0.2, 0.25, 0.3, 0.35])],
|
[np.array([0.2, 0.25, 0.3, 0.35])],
|
||||||
|
@@ -1,26 +1,18 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from http import HTTPStatus
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Optional, Union
|
from typing import Optional
|
||||||
from unittest.mock import PropertyMock, patch
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
import psutil
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
from xprocess import ProcessStarter
|
||||||
from xprocess import ProcessStarter, XProcess
|
|
||||||
|
|
||||||
from akkudoktoreos.config.config import ConfigEOS, get_config
|
from akkudoktoreos.config.config import ConfigEOS, get_config
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.server.server import get_default_host
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -56,12 +48,6 @@ def pytest_addoption(parser):
|
|||||||
default=False,
|
default=False,
|
||||||
help="Verify that user config file is non-existent (will also fail if user config file exists before test run).",
|
help="Verify that user config file is non-existent (will also fail if user config file exists before test run).",
|
||||||
)
|
)
|
||||||
parser.addoption(
|
|
||||||
"--system-test",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="System test mode. Tests may access real resources, like prediction providers!",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -78,18 +64,6 @@ def config_mixin(config_eos):
|
|||||||
yield config_mixin_patch
|
yield config_mixin_patch
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def is_system_test(request):
|
|
||||||
yield bool(request.config.getoption("--system-test"))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prediction_eos():
|
|
||||||
from akkudoktoreos.prediction.prediction import get_prediction
|
|
||||||
|
|
||||||
return get_prediction()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def devices_eos(config_mixin):
|
def devices_eos(config_mixin):
|
||||||
from akkudoktoreos.devices.devices import get_devices
|
from akkudoktoreos.devices.devices import get_devices
|
||||||
@@ -113,33 +87,13 @@ def devices_mixin(devices_eos):
|
|||||||
# Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json)
|
# Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json)
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def cfg_non_existent(request):
|
def cfg_non_existent(request):
|
||||||
if not bool(request.config.getoption("--check-config-side-effect")):
|
|
||||||
yield
|
|
||||||
return
|
|
||||||
|
|
||||||
# Before test
|
|
||||||
from platformdirs import user_config_dir
|
|
||||||
|
|
||||||
user_dir = user_config_dir(ConfigEOS.APP_NAME)
|
|
||||||
user_config_file = Path(user_dir).joinpath(ConfigEOS.CONFIG_FILE_NAME)
|
|
||||||
cwd_config_file = Path.cwd().joinpath(ConfigEOS.CONFIG_FILE_NAME)
|
|
||||||
assert not user_config_file.exists(), (
|
|
||||||
f"Config file {user_config_file} exists, please delete before test!"
|
|
||||||
)
|
|
||||||
assert not cwd_config_file.exists(), (
|
|
||||||
f"Config file {cwd_config_file} exists, please delete before test!"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Yield to test
|
|
||||||
yield
|
yield
|
||||||
|
if bool(request.config.getoption("--check-config-side-effect")):
|
||||||
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
# After test
|
user_dir = user_config_dir(ConfigEOS.APP_NAME)
|
||||||
assert not user_config_file.exists(), (
|
assert not Path(user_dir).joinpath(ConfigEOS.CONFIG_FILE_NAME).exists()
|
||||||
f"Config file {user_config_file} created, please check test!"
|
assert not Path.cwd().joinpath(ConfigEOS.CONFIG_FILE_NAME).exists()
|
||||||
)
|
|
||||||
assert not cwd_config_file.exists(), (
|
|
||||||
f"Config file {cwd_config_file} created, please check test!"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -195,252 +149,52 @@ def config_eos(
|
|||||||
assert config_file.exists()
|
assert config_file.exists()
|
||||||
assert not config_file_cwd.exists()
|
assert not config_file_cwd.exists()
|
||||||
assert config_default_dirs[-1] / "data" == config_eos.general.data_folder_path
|
assert config_default_dirs[-1] / "data" == config_eos.general.data_folder_path
|
||||||
assert config_default_dirs[-1] / "data/cache" == config_eos.cache.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
|
assert config_default_dirs[-1] / "data/output" == config_eos.general.data_output_path
|
||||||
return config_eos
|
return config_eos
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def config_default_dirs(tmpdir):
|
def config_default_dirs():
|
||||||
"""Fixture that provides a list of directories to be used as config dir."""
|
"""Fixture that provides a list of directories to be used as config dir."""
|
||||||
tmp_user_home_dir = Path(tmpdir)
|
with tempfile.TemporaryDirectory() as tmp_user_home_dir:
|
||||||
|
# Default config directory from platform user config directory
|
||||||
|
config_default_dir_user = Path(tmp_user_home_dir) / "config"
|
||||||
|
|
||||||
# Default config directory from platform user config directory
|
# Default config directory from current working directory
|
||||||
config_default_dir_user = tmp_user_home_dir / "config"
|
config_default_dir_cwd = Path(tmp_user_home_dir) / "cwd"
|
||||||
|
config_default_dir_cwd.mkdir()
|
||||||
|
|
||||||
# Default config directory from current working directory
|
# Default config directory from default config file
|
||||||
config_default_dir_cwd = tmp_user_home_dir / "cwd"
|
config_default_dir_default = Path(__file__).parent.parent.joinpath("src/akkudoktoreos/data")
|
||||||
config_default_dir_cwd.mkdir()
|
|
||||||
|
|
||||||
# Default config directory from default config file
|
# Default data directory from platform user data directory
|
||||||
config_default_dir_default = Path(__file__).parent.parent.joinpath("src/akkudoktoreos/data")
|
data_default_dir_user = Path(tmp_user_home_dir)
|
||||||
|
yield (
|
||||||
# Default data directory from platform user data directory
|
config_default_dir_user,
|
||||||
data_default_dir_user = tmp_user_home_dir
|
config_default_dir_cwd,
|
||||||
|
config_default_dir_default,
|
||||||
return (
|
data_default_dir_user,
|
||||||
config_default_dir_user,
|
)
|
||||||
config_default_dir_cwd,
|
|
||||||
config_default_dir_default,
|
|
||||||
data_default_dir_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def server_base(xprocess: XProcess) -> Generator[dict[str, Union[str, int]], None, None]:
|
|
||||||
"""Fixture to start the server with temporary EOS_DIR and default config.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
xprocess (XProcess): The pytest-xprocess fixture to manage the server process.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
dict[str, str]: A dictionary containing:
|
|
||||||
- "server" (str): URL of the server.
|
|
||||||
- "eos_dir" (str): Path to the temporary EOS_DIR.
|
|
||||||
"""
|
|
||||||
host = get_default_host()
|
|
||||||
port = 8503
|
|
||||||
eosdash_port = 8504
|
|
||||||
|
|
||||||
# Port of server may be still blocked by a server usage despite the other server already
|
|
||||||
# shut down. CLOSE_WAIT, TIME_WAIT may typically take up to 120 seconds.
|
|
||||||
server_timeout = 120
|
|
||||||
|
|
||||||
server = f"http://{host}:{port}"
|
|
||||||
eosdash_server = f"http://{host}:{eosdash_port}"
|
|
||||||
eos_tmp_dir = tempfile.TemporaryDirectory()
|
|
||||||
eos_dir = str(eos_tmp_dir.name)
|
|
||||||
|
|
||||||
class Starter(ProcessStarter):
|
|
||||||
# assure server to be installed
|
|
||||||
try:
|
|
||||||
project_dir = Path(__file__).parent.parent
|
|
||||||
subprocess.run(
|
|
||||||
[sys.executable, "-c", "import", "akkudoktoreos.server.eos"],
|
|
||||||
check=True,
|
|
||||||
env=os.environ,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
cwd=project_dir,
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
subprocess.run(
|
|
||||||
[sys.executable, "-m", "pip", "install", "-e", str(project_dir)],
|
|
||||||
env=os.environ,
|
|
||||||
check=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
cwd=project_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set environment for server run
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["EOS_DIR"] = eos_dir
|
|
||||||
env["EOS_CONFIG_DIR"] = eos_dir
|
|
||||||
|
|
||||||
# command to start server process
|
|
||||||
args = [
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"akkudoktoreos.server.eos",
|
|
||||||
"--host",
|
|
||||||
host,
|
|
||||||
"--port",
|
|
||||||
str(port),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Will wait for 'server_timeout' seconds before timing out
|
|
||||||
timeout = server_timeout
|
|
||||||
|
|
||||||
# xprocess will now attempt to clean up upon interruptions
|
|
||||||
terminate_on_interrupt = True
|
|
||||||
|
|
||||||
# checks if our server is ready
|
|
||||||
def startup_check(self):
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{server}/v1/health", timeout=2)
|
|
||||||
if result.status_code == 200:
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
def cleanup_eos_eosdash():
|
|
||||||
# Cleanup any EOS process left.
|
|
||||||
if os.name == "nt":
|
|
||||||
# Windows does not provide SIGKILL
|
|
||||||
sigkill = signal.SIGTERM
|
|
||||||
else:
|
|
||||||
sigkill = signal.SIGKILL # type: ignore
|
|
||||||
# - Use pid on EOS health endpoint
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{server}/v1/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
pid = result.json()["pid"]
|
|
||||||
os.kill(pid, sigkill)
|
|
||||||
time.sleep(1)
|
|
||||||
result = requests.get(f"{server}/v1/health", timeout=2)
|
|
||||||
assert result.status_code != HTTPStatus.OK
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
# - Use pids from processes on EOS port
|
|
||||||
for retries in range(int(server_timeout / 3)):
|
|
||||||
pids: list[int] = []
|
|
||||||
for conn in psutil.net_connections(kind="inet"):
|
|
||||||
if conn.laddr.port == port:
|
|
||||||
if conn.pid not in pids:
|
|
||||||
# Get fresh process info
|
|
||||||
try:
|
|
||||||
process = psutil.Process(conn.pid)
|
|
||||||
process_info = process.as_dict(attrs=["pid", "cmdline"])
|
|
||||||
if "akkudoktoreos.server.eos" in process_info["cmdline"]:
|
|
||||||
pids.append(conn.pid)
|
|
||||||
except:
|
|
||||||
# PID may already be dead
|
|
||||||
pass
|
|
||||||
for pid in pids:
|
|
||||||
os.kill(pid, sigkill)
|
|
||||||
if len(pids) == 0:
|
|
||||||
break
|
|
||||||
time.sleep(3)
|
|
||||||
assert len(pids) == 0
|
|
||||||
# Cleanup any EOSdash processes left.
|
|
||||||
# - Use pid on EOSdash health endpoint
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
pid = result.json()["pid"]
|
|
||||||
os.kill(pid, sigkill)
|
|
||||||
time.sleep(1)
|
|
||||||
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
|
|
||||||
assert result.status_code != HTTPStatus.OK
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
# - Use pids from processes on EOSdash port
|
|
||||||
for retries in range(int(server_timeout / 3)):
|
|
||||||
pids = []
|
|
||||||
for conn in psutil.net_connections(kind="inet"):
|
|
||||||
if conn.laddr.port == eosdash_port:
|
|
||||||
if conn.pid not in pids:
|
|
||||||
# Get fresh process info
|
|
||||||
try:
|
|
||||||
process = psutil.Process(conn.pid)
|
|
||||||
process_info = process.as_dict(attrs=["pid", "cmdline"])
|
|
||||||
if "akkudoktoreos.server.eosdash" in process_info["cmdline"]:
|
|
||||||
pids.append(conn.pid)
|
|
||||||
except:
|
|
||||||
# PID may already be dead
|
|
||||||
pass
|
|
||||||
for pid in pids:
|
|
||||||
os.kill(pid, sigkill)
|
|
||||||
if len(pids) == 0:
|
|
||||||
break
|
|
||||||
time.sleep(3)
|
|
||||||
assert len(pids) == 0
|
|
||||||
|
|
||||||
# Kill all running eos and eosdash process - just to be sure
|
|
||||||
cleanup_eos_eosdash()
|
|
||||||
|
|
||||||
# Ensure there is an empty config file in the temporary EOS directory
|
|
||||||
config_file_path = Path(eos_dir).joinpath(ConfigEOS.CONFIG_FILE_NAME)
|
|
||||||
with config_file_path.open(mode="w", encoding="utf-8", newline="\n") as fd:
|
|
||||||
json.dump({}, fd)
|
|
||||||
|
|
||||||
# ensure process is running and return its logfile
|
|
||||||
pid, logfile = xprocess.ensure("eos", Starter)
|
|
||||||
logger.info(f"Started EOS ({pid}). This may take very long (up to {server_timeout} seconds).")
|
|
||||||
logger.info(f"View xprocess logfile at: {logfile}")
|
|
||||||
|
|
||||||
yield {
|
|
||||||
"server": server,
|
|
||||||
"eosdash_server": eosdash_server,
|
|
||||||
"eos_dir": eos_dir,
|
|
||||||
"timeout": server_timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
# clean up whole process tree afterwards
|
|
||||||
xprocess.getinfo("eos").terminate()
|
|
||||||
|
|
||||||
# Cleanup any EOS process left.
|
|
||||||
cleanup_eos_eosdash()
|
|
||||||
|
|
||||||
# Remove temporary EOS_DIR
|
|
||||||
eos_tmp_dir.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="class")
|
|
||||||
def server_setup_for_class(xprocess) -> Generator[dict[str, Union[str, int]], None, None]:
|
|
||||||
"""A fixture to start the server for a test class."""
|
|
||||||
with server_base(xprocess) as result:
|
|
||||||
yield result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
def server_setup_for_function(xprocess) -> Generator[dict[str, Union[str, int]], None, None]:
|
|
||||||
"""A fixture to start the server for a test function."""
|
|
||||||
with server_base(xprocess) as result:
|
|
||||||
yield result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def server(xprocess, config_eos, config_default_dirs) -> Generator[str, None, None]:
|
def server(xprocess, config_eos, config_default_dirs):
|
||||||
"""Fixture to start the server.
|
"""Fixture to start the server.
|
||||||
|
|
||||||
Provides URL of the server.
|
Provides URL of the server.
|
||||||
"""
|
"""
|
||||||
# create url/port info to the server
|
|
||||||
url = "http://0.0.0.0:8503"
|
|
||||||
|
|
||||||
class Starter(ProcessStarter):
|
class Starter(ProcessStarter):
|
||||||
# Set environment before any subprocess run, to keep custom config dir
|
# Set environment before any subprocess run, to keep custom config dir
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["EOS_DIR"] = str(config_default_dirs[-1])
|
env["EOS_DIR"] = str(config_default_dirs[-1])
|
||||||
project_dir = config_eos.package_root_path.parent.parent
|
project_dir = config_eos.package_root_path
|
||||||
|
|
||||||
# assure server to be installed
|
# assure server to be installed
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[sys.executable, "-c", "import", "akkudoktoreos.server.eos"],
|
[sys.executable, "-c", "import akkudoktoreos.server.eos"],
|
||||||
check=True,
|
check=True,
|
||||||
env=env,
|
env=env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
@@ -449,7 +203,7 @@ def server(xprocess, config_eos, config_default_dirs) -> Generator[str, None, No
|
|||||||
)
|
)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "install", "-e", str(project_dir)],
|
[sys.executable, "-m", "pip", "install", "-e", project_dir],
|
||||||
check=True,
|
check=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
@@ -458,26 +212,24 @@ def server(xprocess, config_eos, config_default_dirs) -> Generator[str, None, No
|
|||||||
# command to start server process
|
# command to start server process
|
||||||
args = [sys.executable, "-m", "akkudoktoreos.server.eos"]
|
args = [sys.executable, "-m", "akkudoktoreos.server.eos"]
|
||||||
|
|
||||||
# will wait for xx seconds before timing out
|
# startup pattern
|
||||||
timeout = 10
|
pattern = "Application startup complete."
|
||||||
|
# search this number of lines for the startup pattern, if not found
|
||||||
|
# a RuntimeError will be raised informing the user
|
||||||
|
max_read_lines = 30
|
||||||
|
|
||||||
|
# will wait for 30 seconds before timing out
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
# xprocess will now attempt to clean up upon interruptions
|
# xprocess will now attempt to clean up upon interruptions
|
||||||
terminate_on_interrupt = True
|
terminate_on_interrupt = True
|
||||||
|
|
||||||
# checks if our server is ready
|
|
||||||
def startup_check(self):
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{url}/v1/health")
|
|
||||||
if result.status_code == 200:
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ensure process is running and return its logfile
|
# ensure process is running and return its logfile
|
||||||
pid, logfile = xprocess.ensure("eos", Starter)
|
pid, logfile = xprocess.ensure("eos", Starter)
|
||||||
print(f"View xprocess logfile at: {logfile}")
|
print(f"View xprocess logfile at: {logfile}")
|
||||||
|
|
||||||
|
# create url/port info to the server
|
||||||
|
url = "http://127.0.0.1:8503"
|
||||||
yield url
|
yield url
|
||||||
|
|
||||||
# clean up whole process tree afterwards
|
# clean up whole process tree afterwards
|
||||||
|
@@ -115,9 +115,9 @@ def test_soc_limits(setup_pv_battery):
|
|||||||
|
|
||||||
def test_max_charge_power_w(setup_pv_battery):
|
def test_max_charge_power_w(setup_pv_battery):
|
||||||
battery = setup_pv_battery
|
battery = setup_pv_battery
|
||||||
assert battery.parameters.max_charge_power_w == 8000, (
|
assert (
|
||||||
"Default max charge power should be 5000W, We ask for 8000W here"
|
battery.parameters.max_charge_power_w == 8000
|
||||||
)
|
), "Default max charge power should be 5000W, We ask for 8000W here"
|
||||||
|
|
||||||
|
|
||||||
def test_charge_energy_within_limits(setup_pv_battery):
|
def test_charge_energy_within_limits(setup_pv_battery):
|
||||||
@@ -139,9 +139,9 @@ def test_charge_energy_exceeds_capacity(setup_pv_battery):
|
|||||||
# Try to overcharge beyond max capacity
|
# Try to overcharge beyond max capacity
|
||||||
charged_wh, losses_wh = battery.charge_energy(wh=20000, hour=2)
|
charged_wh, losses_wh = battery.charge_energy(wh=20000, hour=2)
|
||||||
|
|
||||||
assert charged_wh + initial_soc_wh <= battery.max_soc_wh, (
|
assert (
|
||||||
"Charging should not exceed max capacity"
|
charged_wh + initial_soc_wh <= battery.max_soc_wh
|
||||||
)
|
), "Charging should not exceed max capacity"
|
||||||
assert losses_wh >= 0, "Losses should not be negative"
|
assert losses_wh >= 0, "Losses should not be negative"
|
||||||
assert battery.soc_wh == battery.max_soc_wh, "SOC should be at max after overcharge attempt"
|
assert battery.soc_wh == battery.max_soc_wh, "SOC should be at max after overcharge attempt"
|
||||||
|
|
||||||
@@ -169,9 +169,9 @@ def test_charge_energy_relative_power(setup_pv_battery):
|
|||||||
|
|
||||||
assert charged_wh > 0, "Charging should occur with relative power"
|
assert charged_wh > 0, "Charging should occur with relative power"
|
||||||
assert losses_wh >= 0, "Losses should not be negative"
|
assert losses_wh >= 0, "Losses should not be negative"
|
||||||
assert charged_wh <= battery.max_charge_power_w * relative_power, (
|
assert (
|
||||||
"Charging should respect relative power limit"
|
charged_wh <= battery.max_charge_power_w * relative_power
|
||||||
)
|
), "Charging should respect relative power limit"
|
||||||
assert battery.soc_wh > 0, "SOC should increase after charging"
|
assert battery.soc_wh > 0, "SOC should increase after charging"
|
||||||
|
|
||||||
|
|
||||||
@@ -200,19 +200,19 @@ def test_car_and_pv_battery_discharge_and_max_charge_power(setup_pv_battery, set
|
|||||||
# Test discharge for PV battery
|
# Test discharge for PV battery
|
||||||
pv_discharged_wh, pv_loss_wh = pv_battery.discharge_energy(3000, 5)
|
pv_discharged_wh, pv_loss_wh = pv_battery.discharge_energy(3000, 5)
|
||||||
assert pv_discharged_wh > 0, "PV battery should discharge energy"
|
assert pv_discharged_wh > 0, "PV battery should discharge energy"
|
||||||
assert pv_battery.current_soc_percentage() >= pv_battery.parameters.min_soc_percentage, (
|
assert (
|
||||||
"PV battery SOC should stay above min SOC"
|
pv_battery.current_soc_percentage() >= pv_battery.parameters.min_soc_percentage
|
||||||
)
|
), "PV battery SOC should stay above min SOC"
|
||||||
assert pv_battery.parameters.max_charge_power_w == 8000, (
|
assert (
|
||||||
"PV battery max charge power should remain as defined"
|
pv_battery.parameters.max_charge_power_w == 8000
|
||||||
)
|
), "PV battery max charge power should remain as defined"
|
||||||
|
|
||||||
# Test discharge for car battery
|
# Test discharge for car battery
|
||||||
car_discharged_wh, car_loss_wh = car_battery.discharge_energy(5000, 10)
|
car_discharged_wh, car_loss_wh = car_battery.discharge_energy(5000, 10)
|
||||||
assert car_discharged_wh > 0, "Car battery should discharge energy"
|
assert car_discharged_wh > 0, "Car battery should discharge energy"
|
||||||
assert car_battery.current_soc_percentage() >= car_battery.parameters.min_soc_percentage, (
|
assert (
|
||||||
"Car battery SOC should stay above min SOC"
|
car_battery.current_soc_percentage() >= car_battery.parameters.min_soc_percentage
|
||||||
)
|
), "Car battery SOC should stay above min SOC"
|
||||||
assert car_battery.parameters.max_charge_power_w == 7000, (
|
assert (
|
||||||
"Car battery max charge power should remain as defined"
|
car_battery.parameters.max_charge_power_w == 7000
|
||||||
)
|
), "Car battery max charge power should remain as defined"
|
||||||
|
@@ -1,694 +0,0 @@
|
|||||||
import io
|
|
||||||
import json
|
|
||||||
import pickle
|
|
||||||
import tempfile
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from time import sleep
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import cachebox
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import (
|
|
||||||
CacheFileRecord,
|
|
||||||
CacheFileStore,
|
|
||||||
CacheUntilUpdateStore,
|
|
||||||
cache_in_file,
|
|
||||||
cache_until_update,
|
|
||||||
cachemethod_until_update,
|
|
||||||
)
|
|
||||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
|
||||||
|
|
||||||
# ---------------------------------
|
|
||||||
# In-Memory Caching Functionality
|
|
||||||
# ---------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# Fixtures for testing
|
|
||||||
@pytest.fixture
|
|
||||||
def cache_until_update_store():
|
|
||||||
"""Ensures CacheUntilUpdateStore is reset between tests."""
|
|
||||||
cache = CacheUntilUpdateStore()
|
|
||||||
CacheUntilUpdateStore().clear()
|
|
||||||
assert len(cache) == 0
|
|
||||||
return cache
|
|
||||||
|
|
||||||
|
|
||||||
class TestCacheUntilUpdateStore:
|
|
||||||
def test_cache_initialization(self, cache_until_update_store):
|
|
||||||
"""Test that CacheUntilUpdateStore initializes with the correct properties."""
|
|
||||||
cache = CacheUntilUpdateStore()
|
|
||||||
assert isinstance(cache.cache, cachebox.LRUCache)
|
|
||||||
assert cache.maxsize == 100
|
|
||||||
assert len(cache) == 0
|
|
||||||
|
|
||||||
def test_singleton_behavior(self, cache_until_update_store):
|
|
||||||
"""Test that CacheUntilUpdateStore is a singleton."""
|
|
||||||
cache1 = CacheUntilUpdateStore()
|
|
||||||
cache2 = CacheUntilUpdateStore()
|
|
||||||
assert cache1 is cache2
|
|
||||||
|
|
||||||
def test_cache_storage(self, cache_until_update_store):
|
|
||||||
"""Test that items can be added and retrieved from the cache."""
|
|
||||||
cache = CacheUntilUpdateStore()
|
|
||||||
cache["key1"] = "value1"
|
|
||||||
assert cache["key1"] == "value1"
|
|
||||||
assert len(cache) == 1
|
|
||||||
|
|
||||||
def test_cache_getattr_invalid_method(self, cache_until_update_store):
|
|
||||||
"""Test that accessing an invalid method raises an AttributeError."""
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
CacheUntilUpdateStore().non_existent_method() # This should raise AttributeError
|
|
||||||
|
|
||||||
|
|
||||||
class TestCacheUntilUpdateDecorators:
|
|
||||||
def test_cachemethod_until_update(self, cache_until_update_store):
|
|
||||||
"""Test that cachemethod_until_update caches method results."""
|
|
||||||
|
|
||||||
class MyClass:
|
|
||||||
@cachemethod_until_update
|
|
||||||
def compute(self, value: int) -> int:
|
|
||||||
return value * 2
|
|
||||||
|
|
||||||
obj = MyClass()
|
|
||||||
|
|
||||||
# Call method and assert caching
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 0
|
|
||||||
assert CacheUntilUpdateStore.hit_count == 0
|
|
||||||
result1 = obj.compute(5)
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 1
|
|
||||||
assert CacheUntilUpdateStore.hit_count == 0
|
|
||||||
result2 = obj.compute(5)
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 1
|
|
||||||
assert CacheUntilUpdateStore.hit_count == 1
|
|
||||||
assert result1 == result2
|
|
||||||
|
|
||||||
def test_cache_until_update(self, cache_until_update_store):
|
|
||||||
"""Test that cache_until_update caches function results."""
|
|
||||||
|
|
||||||
@cache_until_update
|
|
||||||
def compute(value: int) -> int:
|
|
||||||
return value * 3
|
|
||||||
|
|
||||||
# Call function and assert caching
|
|
||||||
result1 = compute(4)
|
|
||||||
assert CacheUntilUpdateStore.last_event == cachebox.EVENT_MISS
|
|
||||||
result2 = compute(4)
|
|
||||||
assert CacheUntilUpdateStore.last_event == cachebox.EVENT_HIT
|
|
||||||
assert result1 == result2
|
|
||||||
|
|
||||||
def test_cache_with_different_arguments(self, cache_until_update_store):
|
|
||||||
"""Test that caching works for different arguments."""
|
|
||||||
|
|
||||||
class MyClass:
|
|
||||||
@cachemethod_until_update
|
|
||||||
def compute(self, value: int) -> int:
|
|
||||||
return value * 2
|
|
||||||
|
|
||||||
obj = MyClass()
|
|
||||||
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 0
|
|
||||||
result1 = obj.compute(3)
|
|
||||||
assert CacheUntilUpdateStore.last_event == cachebox.EVENT_MISS
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 1
|
|
||||||
result2 = obj.compute(5)
|
|
||||||
assert CacheUntilUpdateStore.last_event == cachebox.EVENT_MISS
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 2
|
|
||||||
|
|
||||||
assert result1 == 6
|
|
||||||
assert result2 == 10
|
|
||||||
|
|
||||||
def test_cache_clearing(self, cache_until_update_store):
|
|
||||||
"""Test that cache is cleared between EMS update cycles."""
|
|
||||||
|
|
||||||
class MyClass:
|
|
||||||
@cachemethod_until_update
|
|
||||||
def compute(self, value: int) -> int:
|
|
||||||
return value * 2
|
|
||||||
|
|
||||||
obj = MyClass()
|
|
||||||
obj.compute(5)
|
|
||||||
|
|
||||||
# Clear cache
|
|
||||||
CacheUntilUpdateStore().clear()
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
_ = CacheUntilUpdateStore()["<invalid>"]
|
|
||||||
|
|
||||||
def test_decorator_works_for_standalone_function(self, cache_until_update_store):
|
|
||||||
"""Test that cache_until_update works with standalone functions."""
|
|
||||||
|
|
||||||
@cache_until_update
|
|
||||||
def add(a: int, b: int) -> int:
|
|
||||||
return a + b
|
|
||||||
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 0
|
|
||||||
assert CacheUntilUpdateStore.hit_count == 0
|
|
||||||
result1 = add(1, 2)
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 1
|
|
||||||
assert CacheUntilUpdateStore.hit_count == 0
|
|
||||||
result2 = add(1, 2)
|
|
||||||
assert CacheUntilUpdateStore.miss_count == 1
|
|
||||||
assert CacheUntilUpdateStore.hit_count == 1
|
|
||||||
|
|
||||||
assert result1 == result2
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# CacheFileStore
|
|
||||||
# -----------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_store_file():
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
|
||||||
yield Path(temp_file.file.name)
|
|
||||||
# temp_file.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def cache_file_store(temp_store_file):
|
|
||||||
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
|
|
||||||
cache = CacheFileStore()
|
|
||||||
cache._store_file = temp_store_file
|
|
||||||
cache.clear(clear_all=True)
|
|
||||||
assert len(cache._store) == 0
|
|
||||||
return cache
|
|
||||||
|
|
||||||
|
|
||||||
class TestCacheFileStore:
|
|
||||||
def test_generate_cache_file_key(self, cache_file_store):
|
|
||||||
"""Test cache file key generation based on URL and date."""
|
|
||||||
key = "http://example.com"
|
|
||||||
|
|
||||||
# Provide until date - assure until_dt is used.
|
|
||||||
until_dt = to_datetime("2024-10-01")
|
|
||||||
cache_file_key, cache_file_until_dt, ttl_duration = (
|
|
||||||
cache_file_store._generate_cache_file_key(key=key, until_datetime=until_dt)
|
|
||||||
)
|
|
||||||
assert cache_file_key is not None
|
|
||||||
assert compare_datetimes(cache_file_until_dt, until_dt).equal
|
|
||||||
|
|
||||||
# Provide until date again - assure same key is generated.
|
|
||||||
cache_file_key1, cache_file_until_dt1, ttl_duration1 = (
|
|
||||||
cache_file_store._generate_cache_file_key(key=key, until_datetime=until_dt)
|
|
||||||
)
|
|
||||||
assert cache_file_key1 == cache_file_key
|
|
||||||
assert compare_datetimes(cache_file_until_dt1, until_dt).equal
|
|
||||||
|
|
||||||
# Provide no until date - assure today EOD is used.
|
|
||||||
no_until_dt = to_datetime().end_of("day")
|
|
||||||
cache_file_key, cache_file_until_dt, ttl_duration = (
|
|
||||||
cache_file_store._generate_cache_file_key(key)
|
|
||||||
)
|
|
||||||
assert cache_file_key is not None
|
|
||||||
assert compare_datetimes(cache_file_until_dt, no_until_dt).equal
|
|
||||||
|
|
||||||
# Provide with_ttl - assure until_dt is used.
|
|
||||||
until_dt = to_datetime().add(hours=1)
|
|
||||||
cache_file_key, cache_file_until_dt, ttl_duration = (
|
|
||||||
cache_file_store._generate_cache_file_key(key, with_ttl="1 hour")
|
|
||||||
)
|
|
||||||
assert cache_file_key is not None
|
|
||||||
assert compare_datetimes(cache_file_until_dt, until_dt).approximately_equal
|
|
||||||
assert ttl_duration == to_duration("1 hour")
|
|
||||||
|
|
||||||
# Provide with_ttl again - assure same key is generated.
|
|
||||||
until_dt = to_datetime().add(hours=1)
|
|
||||||
cache_file_key1, cache_file_until_dt1, ttl_duration1 = (
|
|
||||||
cache_file_store._generate_cache_file_key(key=key, with_ttl="1 hour")
|
|
||||||
)
|
|
||||||
assert cache_file_key1 == cache_file_key
|
|
||||||
assert compare_datetimes(cache_file_until_dt1, until_dt).approximately_equal
|
|
||||||
assert ttl_duration1 == to_duration("1 hour")
|
|
||||||
|
|
||||||
# Provide different with_ttl - assure different key is generated.
|
|
||||||
until_dt = to_datetime().add(hours=1, minutes=1)
|
|
||||||
cache_file_key2, cache_file_until_dt2, ttl_duration2 = (
|
|
||||||
cache_file_store._generate_cache_file_key(key=key, with_ttl="1 hour 1 minute")
|
|
||||||
)
|
|
||||||
assert cache_file_key2 != cache_file_key
|
|
||||||
assert compare_datetimes(cache_file_until_dt2, until_dt).approximately_equal
|
|
||||||
assert ttl_duration2 == to_duration("1 hour 1 minute")
|
|
||||||
|
|
||||||
def test_get_file_path(self, cache_file_store):
|
|
||||||
"""Test get file path from cache file object."""
|
|
||||||
cache_file = cache_file_store.create("test_file", mode="w+", suffix=".txt")
|
|
||||||
file_path = cache_file_store._get_file_path(cache_file)
|
|
||||||
|
|
||||||
assert file_path is not None
|
|
||||||
|
|
||||||
def test_until_datetime_by_options(self, cache_file_store):
|
|
||||||
"""Test until datetime calculation based on options."""
|
|
||||||
now = to_datetime()
|
|
||||||
|
|
||||||
# Test with until_datetime
|
|
||||||
result, ttl_duration = cache_file_store._until_datetime_by_options(until_datetime=now)
|
|
||||||
assert result == now
|
|
||||||
assert ttl_duration is None
|
|
||||||
|
|
||||||
# -- From now on we expect a until_datetime in one hour
|
|
||||||
ttl_duration_expected = to_duration("1 hour")
|
|
||||||
|
|
||||||
# Test with with_ttl as timedelta
|
|
||||||
until_datetime_expected = to_datetime().add(hours=1)
|
|
||||||
ttl = timedelta(hours=1)
|
|
||||||
result, ttl_duration = cache_file_store._until_datetime_by_options(with_ttl=ttl)
|
|
||||||
assert compare_datetimes(result, until_datetime_expected).approximately_equal
|
|
||||||
assert ttl_duration == ttl_duration_expected
|
|
||||||
|
|
||||||
# Test with with_ttl as int (seconds)
|
|
||||||
until_datetime_expected = to_datetime().add(hours=1)
|
|
||||||
ttl_seconds = 3600
|
|
||||||
result, ttl_duration = cache_file_store._until_datetime_by_options(with_ttl=ttl_seconds)
|
|
||||||
assert compare_datetimes(result, until_datetime_expected).approximately_equal
|
|
||||||
assert ttl_duration == ttl_duration_expected
|
|
||||||
|
|
||||||
# Test with with_ttl as string ("1 hour")
|
|
||||||
until_datetime_expected = to_datetime().add(hours=1)
|
|
||||||
ttl_string = "1 hour"
|
|
||||||
result, ttl_duration = cache_file_store._until_datetime_by_options(with_ttl=ttl_string)
|
|
||||||
assert compare_datetimes(result, until_datetime_expected).approximately_equal
|
|
||||||
assert ttl_duration == ttl_duration_expected
|
|
||||||
|
|
||||||
# -- From now on we expect a until_datetime today at end of day
|
|
||||||
until_datetime_expected = to_datetime().end_of("day")
|
|
||||||
ttl_duration_expected = None
|
|
||||||
|
|
||||||
# Test default case (end of today)
|
|
||||||
result, ttl_duration = cache_file_store._until_datetime_by_options()
|
|
||||||
assert compare_datetimes(result, until_datetime_expected).equal
|
|
||||||
assert ttl_duration == ttl_duration_expected
|
|
||||||
|
|
||||||
# -- From now on we expect a until_datetime in one day at end of day
|
|
||||||
until_datetime_expected = to_datetime().add(days=1).end_of("day")
|
|
||||||
assert ttl_duration == ttl_duration_expected
|
|
||||||
|
|
||||||
# Test with until_date as date
|
|
||||||
until_date = date.today() + timedelta(days=1)
|
|
||||||
result, ttl_duration = cache_file_store._until_datetime_by_options(until_date=until_date)
|
|
||||||
assert compare_datetimes(result, until_datetime_expected).equal
|
|
||||||
assert ttl_duration == ttl_duration_expected
|
|
||||||
|
|
||||||
# -- Test with multiple options (until_datetime takes precedence)
|
|
||||||
specific_datetime = to_datetime().add(days=2)
|
|
||||||
result, ttl_duration = cache_file_store._until_datetime_by_options(
|
|
||||||
until_date=to_datetime().add(days=1).date(),
|
|
||||||
until_datetime=specific_datetime,
|
|
||||||
with_ttl=ttl,
|
|
||||||
)
|
|
||||||
assert compare_datetimes(result, specific_datetime).equal
|
|
||||||
assert ttl_duration is None
|
|
||||||
|
|
||||||
# Test with invalid inputs
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
cache_file_store._until_datetime_by_options(until_date="invalid-date")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
cache_file_store._until_datetime_by_options(with_ttl="invalid-ttl")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
cache_file_store._until_datetime_by_options(until_datetime="invalid-datetime")
|
|
||||||
|
|
||||||
def test_create_cache_file(self, cache_file_store):
|
|
||||||
"""Test the creation of a cache file and ensure it is stored correctly."""
|
|
||||||
# Create a cache file for today's date
|
|
||||||
cache_file = cache_file_store.create("test_file", mode="w+", suffix=".txt")
|
|
||||||
|
|
||||||
# Check that the file exists in the store and is a file-like object
|
|
||||||
assert cache_file is not None
|
|
||||||
assert hasattr(cache_file, "name")
|
|
||||||
assert cache_file.name.endswith(".txt")
|
|
||||||
|
|
||||||
# Write some data to the file
|
|
||||||
cache_file.seek(0)
|
|
||||||
cache_file.write("Test data")
|
|
||||||
cache_file.seek(0) # Reset file pointer
|
|
||||||
assert cache_file.read() == "Test data"
|
|
||||||
|
|
||||||
def test_get_cache_file(self, cache_file_store):
|
|
||||||
"""Test retrieving an existing cache file by key."""
|
|
||||||
# Create a cache file and write data to it
|
|
||||||
cache_file = cache_file_store.create("test_file", mode="w+")
|
|
||||||
cache_file.seek(0)
|
|
||||||
cache_file.write("Test data")
|
|
||||||
cache_file.seek(0)
|
|
||||||
|
|
||||||
# Retrieve the cache file and verify the data
|
|
||||||
retrieved_file = cache_file_store.get("test_file")
|
|
||||||
assert retrieved_file is not None
|
|
||||||
retrieved_file.seek(0)
|
|
||||||
assert retrieved_file.read() == "Test data"
|
|
||||||
|
|
||||||
def test_set_custom_file_object(self, cache_file_store):
|
|
||||||
"""Test setting a custom file-like object (BytesIO or StringIO) in the store."""
|
|
||||||
# Create a BytesIO object and set it into the cache
|
|
||||||
file_obj = io.BytesIO(b"Binary data")
|
|
||||||
cache_file_store.set("binary_file", file_obj)
|
|
||||||
|
|
||||||
# Retrieve the file from the store
|
|
||||||
retrieved_file = cache_file_store.get("binary_file")
|
|
||||||
assert isinstance(retrieved_file, io.BytesIO)
|
|
||||||
retrieved_file.seek(0)
|
|
||||||
assert retrieved_file.read() == b"Binary data"
|
|
||||||
|
|
||||||
def test_delete_cache_file(self, cache_file_store):
|
|
||||||
"""Test deleting a cache file from the store."""
|
|
||||||
# Create multiple cache files
|
|
||||||
cache_file1 = cache_file_store.create("file1")
|
|
||||||
assert hasattr(cache_file1, "name")
|
|
||||||
cache_file2 = cache_file_store.create("file2")
|
|
||||||
assert hasattr(cache_file2, "name")
|
|
||||||
|
|
||||||
# Ensure the files are in the store
|
|
||||||
assert cache_file_store.get("file1") is cache_file1
|
|
||||||
assert cache_file_store.get("file2") is cache_file2
|
|
||||||
|
|
||||||
# Delete cache files
|
|
||||||
cache_file_store.delete("file1")
|
|
||||||
cache_file_store.delete("file2")
|
|
||||||
|
|
||||||
# Ensure the store is empty
|
|
||||||
assert cache_file_store.get("file1") is None
|
|
||||||
assert cache_file_store.get("file2") is None
|
|
||||||
|
|
||||||
def test_clear_all_cache_files(self, cache_file_store):
|
|
||||||
"""Test clearing all cache files from the store."""
|
|
||||||
# Create multiple cache files
|
|
||||||
cache_file1 = cache_file_store.create("file1")
|
|
||||||
assert hasattr(cache_file1, "name")
|
|
||||||
cache_file2 = cache_file_store.create("file2")
|
|
||||||
assert hasattr(cache_file2, "name")
|
|
||||||
|
|
||||||
# Ensure the files are in the store
|
|
||||||
assert cache_file_store.get("file1") is cache_file1
|
|
||||||
assert cache_file_store.get("file2") is cache_file2
|
|
||||||
|
|
||||||
current_store = cache_file_store.current_store()
|
|
||||||
assert current_store != {}
|
|
||||||
|
|
||||||
# Clear all cache files
|
|
||||||
cache_file_store.clear(clear_all=True)
|
|
||||||
|
|
||||||
# Ensure the store is empty
|
|
||||||
assert cache_file_store.get("file1") is None
|
|
||||||
assert cache_file_store.get("file2") is None
|
|
||||||
|
|
||||||
current_store = cache_file_store.current_store()
|
|
||||||
assert current_store == {}
|
|
||||||
|
|
||||||
def test_clear_cache_files_by_date(self, cache_file_store):
|
|
||||||
"""Test clearing cache files from the store by date."""
|
|
||||||
# Create multiple cache files
|
|
||||||
cache_file1 = cache_file_store.create("file1")
|
|
||||||
assert hasattr(cache_file1, "name")
|
|
||||||
cache_file2 = cache_file_store.create("file2")
|
|
||||||
assert hasattr(cache_file2, "name")
|
|
||||||
|
|
||||||
# Ensure the files are in the store
|
|
||||||
assert cache_file_store.get("file1") is cache_file1
|
|
||||||
assert cache_file_store.get("file2") is cache_file2
|
|
||||||
|
|
||||||
# Clear cache files that are older than today
|
|
||||||
cache_file_store.clear(before_datetime=to_datetime().start_of("day"))
|
|
||||||
|
|
||||||
# Ensure the files are in the store
|
|
||||||
assert cache_file_store.get("file1") is cache_file1
|
|
||||||
assert cache_file_store.get("file2") is cache_file2
|
|
||||||
|
|
||||||
# Clear cache files that are older than tomorrow
|
|
||||||
cache_file_store.clear(before_datetime=datetime.now() + timedelta(days=1))
|
|
||||||
|
|
||||||
# Ensure the store is empty
|
|
||||||
assert cache_file_store.get("file1") is None
|
|
||||||
assert cache_file_store.get("file2") is None
|
|
||||||
|
|
||||||
def test_cache_file_with_date(self, cache_file_store):
|
|
||||||
"""Test creating and retrieving cache files with a specific date."""
|
|
||||||
# Use a specific date for cache file creation
|
|
||||||
specific_date = datetime(2023, 10, 10)
|
|
||||||
cache_file = cache_file_store.create("dated_file", mode="w+", until_date=specific_date)
|
|
||||||
|
|
||||||
# Write data to the cache file
|
|
||||||
cache_file.write("Dated data")
|
|
||||||
cache_file.seek(0)
|
|
||||||
|
|
||||||
# Retrieve the cache file with the specific date
|
|
||||||
retrieved_file = cache_file_store.get("dated_file", until_date=specific_date)
|
|
||||||
assert retrieved_file is not None
|
|
||||||
retrieved_file.seek(0)
|
|
||||||
assert retrieved_file.read() == "Dated data"
|
|
||||||
|
|
||||||
def test_recreate_existing_cache_file(self, cache_file_store):
|
|
||||||
"""Test creating a cache file with an existing key does not overwrite the existing file."""
|
|
||||||
# Create a cache file
|
|
||||||
cache_file = cache_file_store.create("test_file", mode="w+")
|
|
||||||
cache_file.write("Original data")
|
|
||||||
cache_file.seek(0)
|
|
||||||
|
|
||||||
# Attempt to recreate the same file (should return the existing one)
|
|
||||||
new_file = cache_file_store.create("test_file")
|
|
||||||
assert new_file is cache_file # Should be the same object
|
|
||||||
new_file.seek(0)
|
|
||||||
assert new_file.read() == "Original data" # Data should be preserved
|
|
||||||
|
|
||||||
# Assure cache file store is a singleton
|
|
||||||
cache_file_store2 = CacheFileStore()
|
|
||||||
new_file = cache_file_store2.get("test_file")
|
|
||||||
assert new_file is cache_file # Should be the same object
|
|
||||||
|
|
||||||
def test_cache_file_store_is_singleton(self, cache_file_store):
|
|
||||||
"""Test re-creating a cache store provides the same store."""
|
|
||||||
# Create a cache file
|
|
||||||
cache_file = cache_file_store.create("test_file", mode="w+")
|
|
||||||
cache_file.write("Original data")
|
|
||||||
cache_file.seek(0)
|
|
||||||
|
|
||||||
# Assure cache file store is a singleton
|
|
||||||
cache_file_store2 = CacheFileStore()
|
|
||||||
new_file = cache_file_store2.get("test_file")
|
|
||||||
assert new_file is cache_file # Should be the same object
|
|
||||||
|
|
||||||
def test_cache_file_store_save_store(self, cache_file_store):
|
|
||||||
# Creating a sample cache record
|
|
||||||
cache_file = MagicMock()
|
|
||||||
cache_file.name = "cache_file_path"
|
|
||||||
cache_file.mode = "wb+"
|
|
||||||
cache_record = CacheFileRecord(
|
|
||||||
cache_file=cache_file, until_datetime=to_datetime(), ttl_duration=None
|
|
||||||
)
|
|
||||||
cache_file_store._store = {"test_key": cache_record}
|
|
||||||
|
|
||||||
# Save the store to the file
|
|
||||||
cache_file_store.save_store()
|
|
||||||
|
|
||||||
# Verify the file content
|
|
||||||
with cache_file_store._store_file.open("r", encoding="utf-8", newline=None) as f:
|
|
||||||
store_loaded = json.load(f)
|
|
||||||
assert "test_key" in store_loaded
|
|
||||||
assert store_loaded["test_key"]["cache_file"] == "cache_file_path"
|
|
||||||
assert store_loaded["test_key"]["mode"] == "wb+"
|
|
||||||
assert store_loaded["test_key"]["until_datetime"] == to_datetime(
|
|
||||||
cache_record.until_datetime, as_string=True
|
|
||||||
)
|
|
||||||
assert store_loaded["test_key"]["ttl_duration"] is None
|
|
||||||
|
|
||||||
def test_cache_file_store_load_store(self, cache_file_store):
|
|
||||||
# Creating a sample cache record and save it to the file
|
|
||||||
cache_record = {
|
|
||||||
"test_key": {
|
|
||||||
"cache_file": "cache_file_path",
|
|
||||||
"mode": "wb+",
|
|
||||||
"until_datetime": to_datetime(as_string=True),
|
|
||||||
"ttl_duration": None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with cache_file_store._store_file.open("w", encoding="utf-8", newline="\n") as f:
|
|
||||||
json.dump(cache_record, f, indent=4)
|
|
||||||
|
|
||||||
# Mock the open function to return a MagicMock for the cache file
|
|
||||||
with patch("builtins.open", new_callable=MagicMock) as mock_open:
|
|
||||||
mock_open.return_value.name = "cache_file_path"
|
|
||||||
mock_open.return_value.mode = "wb+"
|
|
||||||
|
|
||||||
# Load the store from the file
|
|
||||||
cache_file_store.load_store()
|
|
||||||
|
|
||||||
# Verify the loaded store
|
|
||||||
assert "test_key" in cache_file_store._store
|
|
||||||
loaded_record = cache_file_store._store["test_key"]
|
|
||||||
assert loaded_record.cache_file.name == "cache_file_path"
|
|
||||||
assert loaded_record.cache_file.mode == "wb+"
|
|
||||||
assert loaded_record.until_datetime == to_datetime(
|
|
||||||
cache_record["test_key"]["until_datetime"]
|
|
||||||
)
|
|
||||||
assert loaded_record.ttl_duration is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCacheFileDecorators:
|
|
||||||
def test_cache_in_file_decorator_caches_function_result(self, cache_file_store):
|
|
||||||
"""Test that the cache_in_file decorator caches a function result."""
|
|
||||||
# Clear store to assure it is empty
|
|
||||||
cache_file_store.clear(clear_all=True)
|
|
||||||
assert len(cache_file_store._store) == 0
|
|
||||||
|
|
||||||
# Define a simple function to decorate
|
|
||||||
@cache_in_file(mode="w+")
|
|
||||||
def my_function(until_date=None):
|
|
||||||
return "Some expensive computation result"
|
|
||||||
|
|
||||||
# Call the decorated function (should store result in cache)
|
|
||||||
result = my_function(until_date=datetime.now() + timedelta(days=1))
|
|
||||||
assert result == "Some expensive computation result"
|
|
||||||
|
|
||||||
# Assert that the create method was called to store the result
|
|
||||||
assert len(cache_file_store._store) == 1
|
|
||||||
|
|
||||||
# Check if the result was written to the cache file
|
|
||||||
key = next(iter(cache_file_store._store))
|
|
||||||
cache_file = cache_file_store._store[key].cache_file
|
|
||||||
assert cache_file is not None
|
|
||||||
|
|
||||||
# Assert correct content was written to the file
|
|
||||||
cache_file.seek(0) # Move to the start of the file
|
|
||||||
assert cache_file.read() == "Some expensive computation result"
|
|
||||||
|
|
||||||
def test_cache_in_file_decorator_uses_cache(self, cache_file_store):
|
|
||||||
"""Test that the cache_in_file decorator reuses cached file on subsequent calls."""
|
|
||||||
# Clear store to assure it is empty
|
|
||||||
cache_file_store.clear(clear_all=True)
|
|
||||||
assert len(cache_file_store._store) == 0
|
|
||||||
|
|
||||||
# Define a simple function to decorate
|
|
||||||
@cache_in_file(mode="w+")
|
|
||||||
def my_function(until_date=None):
|
|
||||||
return "New result"
|
|
||||||
|
|
||||||
# Call the decorated function (should store result in cache)
|
|
||||||
result = my_function(until_date=to_datetime().add(days=1))
|
|
||||||
assert result == "New result"
|
|
||||||
|
|
||||||
# Assert result was written to cache file
|
|
||||||
key = next(iter(cache_file_store._store))
|
|
||||||
cache_file = cache_file_store._store[key].cache_file
|
|
||||||
assert cache_file is not None
|
|
||||||
cache_file.seek(0) # Move to the start of the file
|
|
||||||
assert cache_file.read() == result
|
|
||||||
|
|
||||||
# Modify cache file
|
|
||||||
result2 = "Cached result"
|
|
||||||
cache_file.seek(0)
|
|
||||||
cache_file.write(result2)
|
|
||||||
|
|
||||||
# Call the decorated function again (should get result from cache)
|
|
||||||
result = my_function(until_date=to_datetime().add(days=1))
|
|
||||||
assert result == result2
|
|
||||||
|
|
||||||
def test_cache_in_file_decorator_forces_update_data(self, cache_file_store):
|
|
||||||
"""Test that the cache_in_file decorator reuses cached file on subsequent calls."""
|
|
||||||
# Clear store to assure it is empty
|
|
||||||
cache_file_store.clear(clear_all=True)
|
|
||||||
assert len(cache_file_store._store) == 0
|
|
||||||
|
|
||||||
# Define a simple function to decorate
|
|
||||||
@cache_in_file(mode="w+")
|
|
||||||
def my_function(until_date=None):
|
|
||||||
return "New result"
|
|
||||||
|
|
||||||
until_date = to_datetime().add(days=1).date()
|
|
||||||
|
|
||||||
# Call the decorated function (should store result in cache)
|
|
||||||
result1 = "New result"
|
|
||||||
result = my_function(until_date=until_date)
|
|
||||||
assert result == result1
|
|
||||||
|
|
||||||
# Assert result was written to cache file
|
|
||||||
key = next(iter(cache_file_store._store))
|
|
||||||
cache_file = cache_file_store._store[key].cache_file
|
|
||||||
assert cache_file is not None
|
|
||||||
cache_file.seek(0) # Move to the start of the file
|
|
||||||
assert cache_file.read() == result
|
|
||||||
|
|
||||||
# Modify cache file
|
|
||||||
result2 = "Cached result"
|
|
||||||
cache_file.seek(0)
|
|
||||||
cache_file.write(result2)
|
|
||||||
cache_file.seek(0) # Move to the start of the file
|
|
||||||
assert cache_file.read() == result2
|
|
||||||
|
|
||||||
# Call the decorated function again with force update (should get result from function)
|
|
||||||
result = my_function(until_date=until_date, force_update=True) # type: ignore[call-arg]
|
|
||||||
assert result == result1
|
|
||||||
|
|
||||||
# Assure result was written to the same cache file
|
|
||||||
cache_file.seek(0) # Move to the start of the file
|
|
||||||
assert cache_file.read() == result1
|
|
||||||
|
|
||||||
def test_cache_in_file_handles_ttl(self, cache_file_store):
|
|
||||||
"""Test that the cache_infile decorator handles the with_ttl parameter."""
|
|
||||||
|
|
||||||
# Define a simple function to decorate
|
|
||||||
@cache_in_file(mode="w+")
|
|
||||||
def my_function():
|
|
||||||
return "New result"
|
|
||||||
|
|
||||||
# Call the decorated function
|
|
||||||
result1 = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
|
||||||
assert result1 == "New result"
|
|
||||||
assert len(cache_file_store._store) == 1
|
|
||||||
key = list(cache_file_store._store.keys())[0]
|
|
||||||
|
|
||||||
# Assert result was written to cache file
|
|
||||||
key = next(iter(cache_file_store._store))
|
|
||||||
cache_file = cache_file_store._store[key].cache_file
|
|
||||||
assert cache_file is not None
|
|
||||||
cache_file.seek(0) # Move to the start of the file
|
|
||||||
assert cache_file.read() == result1
|
|
||||||
|
|
||||||
# Modify cache file
|
|
||||||
result2 = "Cached result"
|
|
||||||
cache_file.seek(0)
|
|
||||||
cache_file.write(result2)
|
|
||||||
cache_file.seek(0) # Move to the start of the file
|
|
||||||
assert cache_file.read() == result2
|
|
||||||
|
|
||||||
# Call the decorated function again
|
|
||||||
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
|
||||||
cache_file.seek(0) # Move to the start of the file
|
|
||||||
assert cache_file.read() == result2
|
|
||||||
assert result == result2
|
|
||||||
|
|
||||||
# Wait one second to let the cache time out
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
# Call again - cache should be timed out
|
|
||||||
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
|
||||||
assert result == result1
|
|
||||||
|
|
||||||
def test_cache_in_file_handles_bytes_return(self, cache_file_store):
|
|
||||||
"""Test that the cache_infile decorator handles bytes returned from the function."""
|
|
||||||
# Clear store to assure it is empty
|
|
||||||
cache_file_store.clear(clear_all=True)
|
|
||||||
assert len(cache_file_store._store) == 0
|
|
||||||
|
|
||||||
# Define a function that returns bytes
|
|
||||||
@cache_in_file()
|
|
||||||
def my_function(until_date=None) -> bytes:
|
|
||||||
return b"Some binary data"
|
|
||||||
|
|
||||||
# Call the decorated function
|
|
||||||
result = my_function(until_date=datetime.now() + timedelta(days=1))
|
|
||||||
|
|
||||||
# Check if the binary data was written to the cache file
|
|
||||||
key = next(iter(cache_file_store._store))
|
|
||||||
cache_file = cache_file_store._store[key].cache_file
|
|
||||||
assert len(cache_file_store._store) == 1
|
|
||||||
assert cache_file is not None
|
|
||||||
cache_file.seek(0)
|
|
||||||
result1 = pickle.load(cache_file)
|
|
||||||
assert result1 == result
|
|
||||||
|
|
||||||
# Access cache
|
|
||||||
result = my_function(until_date=datetime.now() + timedelta(days=1))
|
|
||||||
assert len(cache_file_store._store) == 1
|
|
||||||
assert cache_file_store._store[key].cache_file is not None
|
|
||||||
assert result1 == result
|
|
491
tests/test_cacheutil.py
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
"""Test Module for CacheFileStore Module."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import pickle
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from akkudoktoreos.utils.cacheutil import CacheFileStore, cache_in_file
|
||||||
|
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# CacheFileStore
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cache_store():
|
||||||
|
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
|
||||||
|
cache = CacheFileStore()
|
||||||
|
cache.clear(clear_all=True)
|
||||||
|
assert len(cache._store) == 0
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_cache_file_key(cache_store):
|
||||||
|
"""Test cache file key generation based on URL and date."""
|
||||||
|
key = "http://example.com"
|
||||||
|
|
||||||
|
# Provide until date - assure until_dt is used.
|
||||||
|
until_dt = to_datetime("2024-10-01")
|
||||||
|
cache_file_key, cache_file_until_dt, ttl_duration = cache_store._generate_cache_file_key(
|
||||||
|
key=key, until_datetime=until_dt
|
||||||
|
)
|
||||||
|
assert cache_file_key is not None
|
||||||
|
assert compare_datetimes(cache_file_until_dt, until_dt).equal
|
||||||
|
|
||||||
|
# Provide until date again - assure same key is generated.
|
||||||
|
cache_file_key1, cache_file_until_dt1, ttl_duration1 = cache_store._generate_cache_file_key(
|
||||||
|
key=key, until_datetime=until_dt
|
||||||
|
)
|
||||||
|
assert cache_file_key1 == cache_file_key
|
||||||
|
assert compare_datetimes(cache_file_until_dt1, until_dt).equal
|
||||||
|
|
||||||
|
# Provide no until date - assure today EOD is used.
|
||||||
|
no_until_dt = to_datetime().end_of("day")
|
||||||
|
cache_file_key, cache_file_until_dt, ttl_duration = cache_store._generate_cache_file_key(key)
|
||||||
|
assert cache_file_key is not None
|
||||||
|
assert compare_datetimes(cache_file_until_dt, no_until_dt).equal
|
||||||
|
|
||||||
|
# Provide with_ttl - assure until_dt is used.
|
||||||
|
until_dt = to_datetime().add(hours=1)
|
||||||
|
cache_file_key, cache_file_until_dt, ttl_duration = cache_store._generate_cache_file_key(
|
||||||
|
key, with_ttl="1 hour"
|
||||||
|
)
|
||||||
|
assert cache_file_key is not None
|
||||||
|
assert compare_datetimes(cache_file_until_dt, until_dt).approximately_equal
|
||||||
|
assert ttl_duration == to_duration("1 hour")
|
||||||
|
|
||||||
|
# Provide with_ttl again - assure same key is generated.
|
||||||
|
until_dt = to_datetime().add(hours=1)
|
||||||
|
cache_file_key1, cache_file_until_dt1, ttl_duration1 = cache_store._generate_cache_file_key(
|
||||||
|
key=key, with_ttl="1 hour"
|
||||||
|
)
|
||||||
|
assert cache_file_key1 == cache_file_key
|
||||||
|
assert compare_datetimes(cache_file_until_dt1, until_dt).approximately_equal
|
||||||
|
assert ttl_duration1 == to_duration("1 hour")
|
||||||
|
|
||||||
|
# Provide different with_ttl - assure different key is generated.
|
||||||
|
until_dt = to_datetime().add(hours=1, minutes=1)
|
||||||
|
cache_file_key2, cache_file_until_dt2, ttl_duration2 = cache_store._generate_cache_file_key(
|
||||||
|
key=key, with_ttl="1 hour 1 minute"
|
||||||
|
)
|
||||||
|
assert cache_file_key2 != cache_file_key
|
||||||
|
assert compare_datetimes(cache_file_until_dt2, until_dt).approximately_equal
|
||||||
|
assert ttl_duration2 == to_duration("1 hour 1 minute")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_path(cache_store):
|
||||||
|
"""Test get file path from cache file object."""
|
||||||
|
cache_file = cache_store.create("test_file", mode="w+", suffix=".txt")
|
||||||
|
file_path = cache_store._get_file_path(cache_file)
|
||||||
|
|
||||||
|
assert file_path is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_until_datetime_by_options(cache_store):
|
||||||
|
"""Test until datetime calculation based on options."""
|
||||||
|
now = to_datetime()
|
||||||
|
|
||||||
|
# Test with until_datetime
|
||||||
|
result, ttl_duration = cache_store._until_datetime_by_options(until_datetime=now)
|
||||||
|
assert result == now
|
||||||
|
assert ttl_duration is None
|
||||||
|
|
||||||
|
# -- From now on we expect a until_datetime in one hour
|
||||||
|
ttl_duration_expected = to_duration("1 hour")
|
||||||
|
|
||||||
|
# Test with with_ttl as timedelta
|
||||||
|
until_datetime_expected = to_datetime().add(hours=1)
|
||||||
|
ttl = timedelta(hours=1)
|
||||||
|
result, ttl_duration = cache_store._until_datetime_by_options(with_ttl=ttl)
|
||||||
|
assert compare_datetimes(result, until_datetime_expected).approximately_equal
|
||||||
|
assert ttl_duration == ttl_duration_expected
|
||||||
|
|
||||||
|
# Test with with_ttl as int (seconds)
|
||||||
|
until_datetime_expected = to_datetime().add(hours=1)
|
||||||
|
ttl_seconds = 3600
|
||||||
|
result, ttl_duration = cache_store._until_datetime_by_options(with_ttl=ttl_seconds)
|
||||||
|
assert compare_datetimes(result, until_datetime_expected).approximately_equal
|
||||||
|
assert ttl_duration == ttl_duration_expected
|
||||||
|
|
||||||
|
# Test with with_ttl as string ("1 hour")
|
||||||
|
until_datetime_expected = to_datetime().add(hours=1)
|
||||||
|
ttl_string = "1 hour"
|
||||||
|
result, ttl_duration = cache_store._until_datetime_by_options(with_ttl=ttl_string)
|
||||||
|
assert compare_datetimes(result, until_datetime_expected).approximately_equal
|
||||||
|
assert ttl_duration == ttl_duration_expected
|
||||||
|
|
||||||
|
# -- From now on we expect a until_datetime today at end of day
|
||||||
|
until_datetime_expected = to_datetime().end_of("day")
|
||||||
|
ttl_duration_expected = None
|
||||||
|
|
||||||
|
# Test default case (end of today)
|
||||||
|
result, ttl_duration = cache_store._until_datetime_by_options()
|
||||||
|
assert compare_datetimes(result, until_datetime_expected).equal
|
||||||
|
assert ttl_duration == ttl_duration_expected
|
||||||
|
|
||||||
|
# -- From now on we expect a until_datetime in one day at end of day
|
||||||
|
until_datetime_expected = to_datetime().add(days=1).end_of("day")
|
||||||
|
assert ttl_duration == ttl_duration_expected
|
||||||
|
|
||||||
|
# Test with until_date as date
|
||||||
|
until_date = date.today() + timedelta(days=1)
|
||||||
|
result, ttl_duration = cache_store._until_datetime_by_options(until_date=until_date)
|
||||||
|
assert compare_datetimes(result, until_datetime_expected).equal
|
||||||
|
assert ttl_duration == ttl_duration_expected
|
||||||
|
|
||||||
|
# -- Test with multiple options (until_datetime takes precedence)
|
||||||
|
specific_datetime = to_datetime().add(days=2)
|
||||||
|
result, ttl_duration = cache_store._until_datetime_by_options(
|
||||||
|
until_date=to_datetime().add(days=1).date(),
|
||||||
|
until_datetime=specific_datetime,
|
||||||
|
with_ttl=ttl,
|
||||||
|
)
|
||||||
|
assert compare_datetimes(result, specific_datetime).equal
|
||||||
|
assert ttl_duration is None
|
||||||
|
|
||||||
|
# Test with invalid inputs
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cache_store._until_datetime_by_options(until_date="invalid-date")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cache_store._until_datetime_by_options(with_ttl="invalid-ttl")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cache_store._until_datetime_by_options(until_datetime="invalid-datetime")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_cache_file(cache_store):
|
||||||
|
"""Test the creation of a cache file and ensure it is stored correctly."""
|
||||||
|
# Create a cache file for today's date
|
||||||
|
cache_file = cache_store.create("test_file", mode="w+", suffix=".txt")
|
||||||
|
|
||||||
|
# Check that the file exists in the store and is a file-like object
|
||||||
|
assert cache_file is not None
|
||||||
|
assert hasattr(cache_file, "name")
|
||||||
|
assert cache_file.name.endswith(".txt")
|
||||||
|
|
||||||
|
# Write some data to the file
|
||||||
|
cache_file.seek(0)
|
||||||
|
cache_file.write("Test data")
|
||||||
|
cache_file.seek(0) # Reset file pointer
|
||||||
|
assert cache_file.read() == "Test data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cache_file(cache_store):
|
||||||
|
"""Test retrieving an existing cache file by key."""
|
||||||
|
# Create a cache file and write data to it
|
||||||
|
cache_file = cache_store.create("test_file", mode="w+")
|
||||||
|
cache_file.seek(0)
|
||||||
|
cache_file.write("Test data")
|
||||||
|
cache_file.seek(0)
|
||||||
|
|
||||||
|
# Retrieve the cache file and verify the data
|
||||||
|
retrieved_file = cache_store.get("test_file")
|
||||||
|
assert retrieved_file is not None
|
||||||
|
retrieved_file.seek(0)
|
||||||
|
assert retrieved_file.read() == "Test data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_custom_file_object(cache_store):
|
||||||
|
"""Test setting a custom file-like object (BytesIO or StringIO) in the store."""
|
||||||
|
# Create a BytesIO object and set it into the cache
|
||||||
|
file_obj = io.BytesIO(b"Binary data")
|
||||||
|
cache_store.set("binary_file", file_obj)
|
||||||
|
|
||||||
|
# Retrieve the file from the store
|
||||||
|
retrieved_file = cache_store.get("binary_file")
|
||||||
|
assert isinstance(retrieved_file, io.BytesIO)
|
||||||
|
retrieved_file.seek(0)
|
||||||
|
assert retrieved_file.read() == b"Binary data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_cache_file(cache_store):
|
||||||
|
"""Test deleting a cache file from the store."""
|
||||||
|
# Create multiple cache files
|
||||||
|
cache_file1 = cache_store.create("file1")
|
||||||
|
assert hasattr(cache_file1, "name")
|
||||||
|
cache_file2 = cache_store.create("file2")
|
||||||
|
assert hasattr(cache_file2, "name")
|
||||||
|
|
||||||
|
# Ensure the files are in the store
|
||||||
|
assert cache_store.get("file1") is cache_file1
|
||||||
|
assert cache_store.get("file2") is cache_file2
|
||||||
|
|
||||||
|
# Delete cache files
|
||||||
|
cache_store.delete("file1")
|
||||||
|
cache_store.delete("file2")
|
||||||
|
|
||||||
|
# Ensure the store is empty
|
||||||
|
assert cache_store.get("file1") is None
|
||||||
|
assert cache_store.get("file2") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_all_cache_files(cache_store):
|
||||||
|
"""Test clearing all cache files from the store."""
|
||||||
|
# Create multiple cache files
|
||||||
|
cache_file1 = cache_store.create("file1")
|
||||||
|
assert hasattr(cache_file1, "name")
|
||||||
|
cache_file2 = cache_store.create("file2")
|
||||||
|
assert hasattr(cache_file2, "name")
|
||||||
|
|
||||||
|
# Ensure the files are in the store
|
||||||
|
assert cache_store.get("file1") is cache_file1
|
||||||
|
assert cache_store.get("file2") is cache_file2
|
||||||
|
|
||||||
|
# Clear all cache files
|
||||||
|
cache_store.clear(clear_all=True)
|
||||||
|
|
||||||
|
# Ensure the store is empty
|
||||||
|
assert cache_store.get("file1") is None
|
||||||
|
assert cache_store.get("file2") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_cache_files_by_date(cache_store):
|
||||||
|
"""Test clearing cache files from the store by date."""
|
||||||
|
# Create multiple cache files
|
||||||
|
cache_file1 = cache_store.create("file1")
|
||||||
|
assert hasattr(cache_file1, "name")
|
||||||
|
cache_file2 = cache_store.create("file2")
|
||||||
|
assert hasattr(cache_file2, "name")
|
||||||
|
|
||||||
|
# Ensure the files are in the store
|
||||||
|
assert cache_store.get("file1") is cache_file1
|
||||||
|
assert cache_store.get("file2") is cache_file2
|
||||||
|
|
||||||
|
# Clear cache files that are older than today
|
||||||
|
cache_store.clear(before_datetime=to_datetime().start_of("day"))
|
||||||
|
|
||||||
|
# Ensure the files are in the store
|
||||||
|
assert cache_store.get("file1") is cache_file1
|
||||||
|
assert cache_store.get("file2") is cache_file2
|
||||||
|
|
||||||
|
# Clear cache files that are older than tomorrow
|
||||||
|
cache_store.clear(before_datetime=datetime.now() + timedelta(days=1))
|
||||||
|
|
||||||
|
# Ensure the store is empty
|
||||||
|
assert cache_store.get("file1") is None
|
||||||
|
assert cache_store.get("file2") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_file_with_date(cache_store):
|
||||||
|
"""Test creating and retrieving cache files with a specific date."""
|
||||||
|
# Use a specific date for cache file creation
|
||||||
|
specific_date = datetime(2023, 10, 10)
|
||||||
|
cache_file = cache_store.create("dated_file", mode="w+", until_date=specific_date)
|
||||||
|
|
||||||
|
# Write data to the cache file
|
||||||
|
cache_file.write("Dated data")
|
||||||
|
cache_file.seek(0)
|
||||||
|
|
||||||
|
# Retrieve the cache file with the specific date
|
||||||
|
retrieved_file = cache_store.get("dated_file", until_date=specific_date)
|
||||||
|
assert retrieved_file is not None
|
||||||
|
retrieved_file.seek(0)
|
||||||
|
assert retrieved_file.read() == "Dated data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recreate_existing_cache_file(cache_store):
|
||||||
|
"""Test creating a cache file with an existing key does not overwrite the existing file."""
|
||||||
|
# Create a cache file
|
||||||
|
cache_file = cache_store.create("test_file", mode="w+")
|
||||||
|
cache_file.write("Original data")
|
||||||
|
cache_file.seek(0)
|
||||||
|
|
||||||
|
# Attempt to recreate the same file (should return the existing one)
|
||||||
|
new_file = cache_store.create("test_file")
|
||||||
|
assert new_file is cache_file # Should be the same object
|
||||||
|
new_file.seek(0)
|
||||||
|
assert new_file.read() == "Original data" # Data should be preserved
|
||||||
|
|
||||||
|
# Assure cache file store is a singleton
|
||||||
|
cache_store2 = CacheFileStore()
|
||||||
|
new_file = cache_store2.get("test_file")
|
||||||
|
assert new_file is cache_file # Should be the same object
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_store_is_singleton(cache_store):
|
||||||
|
"""Test re-creating a cache store provides the same store."""
|
||||||
|
# Create a cache file
|
||||||
|
cache_file = cache_store.create("test_file", mode="w+")
|
||||||
|
cache_file.write("Original data")
|
||||||
|
cache_file.seek(0)
|
||||||
|
|
||||||
|
# Assure cache file store is a singleton
|
||||||
|
cache_store2 = CacheFileStore()
|
||||||
|
new_file = cache_store2.get("test_file")
|
||||||
|
assert new_file is cache_file # Should be the same object
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_in_file_decorator_caches_function_result(cache_store):
|
||||||
|
"""Test that the cache_in_file decorator caches a function result."""
|
||||||
|
# Clear store to assure it is empty
|
||||||
|
cache_store.clear(clear_all=True)
|
||||||
|
assert len(cache_store._store) == 0
|
||||||
|
|
||||||
|
# Define a simple function to decorate
|
||||||
|
@cache_in_file(mode="w+")
|
||||||
|
def my_function(until_date=None):
|
||||||
|
return "Some expensive computation result"
|
||||||
|
|
||||||
|
# Call the decorated function (should store result in cache)
|
||||||
|
result = my_function(until_date=datetime.now() + timedelta(days=1))
|
||||||
|
assert result == "Some expensive computation result"
|
||||||
|
|
||||||
|
# Assert that the create method was called to store the result
|
||||||
|
assert len(cache_store._store) == 1
|
||||||
|
|
||||||
|
# Check if the result was written to the cache file
|
||||||
|
key = next(iter(cache_store._store))
|
||||||
|
cache_file = cache_store._store[key].cache_file
|
||||||
|
assert cache_file is not None
|
||||||
|
|
||||||
|
# Assert correct content was written to the file
|
||||||
|
cache_file.seek(0) # Move to the start of the file
|
||||||
|
assert cache_file.read() == "Some expensive computation result"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_in_file_decorator_uses_cache(cache_store):
|
||||||
|
"""Test that the cache_in_file decorator reuses cached file on subsequent calls."""
|
||||||
|
# Clear store to assure it is empty
|
||||||
|
cache_store.clear(clear_all=True)
|
||||||
|
assert len(cache_store._store) == 0
|
||||||
|
|
||||||
|
# Define a simple function to decorate
|
||||||
|
@cache_in_file(mode="w+")
|
||||||
|
def my_function(until_date=None):
|
||||||
|
return "New result"
|
||||||
|
|
||||||
|
# Call the decorated function (should store result in cache)
|
||||||
|
result = my_function(until_date=to_datetime().add(days=1))
|
||||||
|
assert result == "New result"
|
||||||
|
|
||||||
|
# Assert result was written to cache file
|
||||||
|
key = next(iter(cache_store._store))
|
||||||
|
cache_file = cache_store._store[key].cache_file
|
||||||
|
assert cache_file is not None
|
||||||
|
cache_file.seek(0) # Move to the start of the file
|
||||||
|
assert cache_file.read() == result
|
||||||
|
|
||||||
|
# Modify cache file
|
||||||
|
result2 = "Cached result"
|
||||||
|
cache_file.seek(0)
|
||||||
|
cache_file.write(result2)
|
||||||
|
|
||||||
|
# Call the decorated function again (should get result from cache)
|
||||||
|
result = my_function(until_date=to_datetime().add(days=1))
|
||||||
|
assert result == result2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_in_file_decorator_forces_update_data(cache_store):
|
||||||
|
"""Test that the cache_in_file decorator reuses cached file on subsequent calls."""
|
||||||
|
# Clear store to assure it is empty
|
||||||
|
cache_store.clear(clear_all=True)
|
||||||
|
assert len(cache_store._store) == 0
|
||||||
|
|
||||||
|
# Define a simple function to decorate
|
||||||
|
@cache_in_file(mode="w+")
|
||||||
|
def my_function(until_date=None):
|
||||||
|
return "New result"
|
||||||
|
|
||||||
|
until_date = to_datetime().add(days=1).date()
|
||||||
|
|
||||||
|
# Call the decorated function (should store result in cache)
|
||||||
|
result1 = "New result"
|
||||||
|
result = my_function(until_date=until_date)
|
||||||
|
assert result == result1
|
||||||
|
|
||||||
|
# Assert result was written to cache file
|
||||||
|
key = next(iter(cache_store._store))
|
||||||
|
cache_file = cache_store._store[key].cache_file
|
||||||
|
assert cache_file is not None
|
||||||
|
cache_file.seek(0) # Move to the start of the file
|
||||||
|
assert cache_file.read() == result
|
||||||
|
|
||||||
|
# Modify cache file
|
||||||
|
result2 = "Cached result"
|
||||||
|
cache_file.seek(0)
|
||||||
|
cache_file.write(result2)
|
||||||
|
cache_file.seek(0) # Move to the start of the file
|
||||||
|
assert cache_file.read() == result2
|
||||||
|
|
||||||
|
# Call the decorated function again with force update (should get result from function)
|
||||||
|
result = my_function(until_date=until_date, force_update=True) # type: ignore[call-arg]
|
||||||
|
assert result == result1
|
||||||
|
|
||||||
|
# Assure result was written to the same cache file
|
||||||
|
cache_file.seek(0) # Move to the start of the file
|
||||||
|
assert cache_file.read() == result1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_in_file_handles_ttl(cache_store):
|
||||||
|
"""Test that the cache_infile decorator handles the with_ttl parameter."""
|
||||||
|
|
||||||
|
# Define a simple function to decorate
|
||||||
|
@cache_in_file(mode="w+")
|
||||||
|
def my_function():
|
||||||
|
return "New result"
|
||||||
|
|
||||||
|
# Call the decorated function
|
||||||
|
result1 = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
||||||
|
assert result1 == "New result"
|
||||||
|
assert len(cache_store._store) == 1
|
||||||
|
key = list(cache_store._store.keys())[0]
|
||||||
|
|
||||||
|
# Assert result was written to cache file
|
||||||
|
key = next(iter(cache_store._store))
|
||||||
|
cache_file = cache_store._store[key].cache_file
|
||||||
|
assert cache_file is not None
|
||||||
|
cache_file.seek(0) # Move to the start of the file
|
||||||
|
assert cache_file.read() == result1
|
||||||
|
|
||||||
|
# Modify cache file
|
||||||
|
result2 = "Cached result"
|
||||||
|
cache_file.seek(0)
|
||||||
|
cache_file.write(result2)
|
||||||
|
cache_file.seek(0) # Move to the start of the file
|
||||||
|
assert cache_file.read() == result2
|
||||||
|
|
||||||
|
# Call the decorated function again
|
||||||
|
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
||||||
|
cache_file.seek(0) # Move to the start of the file
|
||||||
|
assert cache_file.read() == result2
|
||||||
|
assert result == result2
|
||||||
|
|
||||||
|
# Wait one second to let the cache time out
|
||||||
|
sleep(2)
|
||||||
|
|
||||||
|
# Call again - cache should be timed out
|
||||||
|
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
||||||
|
assert result == result1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_in_file_handles_bytes_return(cache_store):
|
||||||
|
"""Test that the cache_infile decorator handles bytes returned from the function."""
|
||||||
|
# Clear store to assure it is empty
|
||||||
|
cache_store.clear(clear_all=True)
|
||||||
|
assert len(cache_store._store) == 0
|
||||||
|
|
||||||
|
# Define a function that returns bytes
|
||||||
|
@cache_in_file()
|
||||||
|
def my_function(until_date=None) -> bytes:
|
||||||
|
return b"Some binary data"
|
||||||
|
|
||||||
|
# Call the decorated function
|
||||||
|
result = my_function(until_date=datetime.now() + timedelta(days=1))
|
||||||
|
|
||||||
|
# Check if the binary data was written to the cache file
|
||||||
|
key = next(iter(cache_store._store))
|
||||||
|
cache_file = cache_store._store[key].cache_file
|
||||||
|
assert len(cache_store._store) == 1
|
||||||
|
assert cache_file is not None
|
||||||
|
cache_file.seek(0)
|
||||||
|
result1 = pickle.load(cache_file)
|
||||||
|
assert result1 == result
|
||||||
|
|
||||||
|
# Access cache
|
||||||
|
result = my_function(until_date=datetime.now() + timedelta(days=1))
|
||||||
|
assert len(cache_store._store) == 1
|
||||||
|
assert cache_store._store[key].cache_file is not None
|
||||||
|
assert result1 == result
|
@@ -2,8 +2,8 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from akkudoktoreos.core.ems import (
|
from akkudoktoreos.core.ems import (
|
||||||
EnergyManagement,
|
EnergieManagementSystem,
|
||||||
EnergyManagementParameters,
|
EnergieManagementSystemParameters,
|
||||||
SimulationResult,
|
SimulationResult,
|
||||||
get_ems,
|
get_ems,
|
||||||
)
|
)
|
||||||
@@ -20,8 +20,8 @@ start_hour = 1
|
|||||||
|
|
||||||
# Example initialization of necessary components
|
# Example initialization of necessary components
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def create_ems_instance(devices_eos, config_eos) -> EnergyManagement:
|
def create_ems_instance(devices_eos, config_eos) -> EnergieManagementSystem:
|
||||||
"""Fixture to create an EnergyManagement instance with given test parameters."""
|
"""Fixture to create an EnergieManagementSystem instance with given test parameters."""
|
||||||
# Assure configuration holds the correct values
|
# Assure configuration holds the correct values
|
||||||
config_eos.merge_settings_from_dict(
|
config_eos.merge_settings_from_dict(
|
||||||
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
||||||
@@ -227,7 +227,7 @@ def create_ems_instance(devices_eos, config_eos) -> EnergyManagement:
|
|||||||
# Initialize the energy management system with the respective parameters
|
# Initialize the energy management system with the respective parameters
|
||||||
ems = get_ems()
|
ems = get_ems()
|
||||||
ems.set_parameters(
|
ems.set_parameters(
|
||||||
EnergyManagementParameters(
|
EnergieManagementSystemParameters(
|
||||||
pv_prognose_wh=pv_prognose_wh,
|
pv_prognose_wh=pv_prognose_wh,
|
||||||
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
||||||
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
||||||
@@ -243,7 +243,7 @@ def create_ems_instance(devices_eos, config_eos) -> EnergyManagement:
|
|||||||
|
|
||||||
|
|
||||||
def test_simulation(create_ems_instance):
|
def test_simulation(create_ems_instance):
|
||||||
"""Test the EnergyManagement simulation method."""
|
"""Test the EnergieManagementSystem simulation method."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
|
|
||||||
# Simulate starting from hour 1 (this value can be adjusted)
|
# Simulate starting from hour 1 (this value can be adjusted)
|
||||||
@@ -281,69 +281,69 @@ def test_simulation(create_ems_instance):
|
|||||||
assert SimulationResult(**result) is not None
|
assert SimulationResult(**result) is not None
|
||||||
|
|
||||||
# Check the length of the main arrays
|
# Check the length of the main arrays
|
||||||
assert len(result["Last_Wh_pro_Stunde"]) == 47, (
|
assert (
|
||||||
"The length of 'Last_Wh_pro_Stunde' should be 48."
|
len(result["Last_Wh_pro_Stunde"]) == 47
|
||||||
)
|
), "The length of 'Last_Wh_pro_Stunde' should be 48."
|
||||||
assert len(result["Netzeinspeisung_Wh_pro_Stunde"]) == 47, (
|
assert (
|
||||||
"The length of 'Netzeinspeisung_Wh_pro_Stunde' should be 48."
|
len(result["Netzeinspeisung_Wh_pro_Stunde"]) == 47
|
||||||
)
|
), "The length of 'Netzeinspeisung_Wh_pro_Stunde' should be 48."
|
||||||
assert len(result["Netzbezug_Wh_pro_Stunde"]) == 47, (
|
assert (
|
||||||
"The length of 'Netzbezug_Wh_pro_Stunde' should be 48."
|
len(result["Netzbezug_Wh_pro_Stunde"]) == 47
|
||||||
)
|
), "The length of 'Netzbezug_Wh_pro_Stunde' should be 48."
|
||||||
assert len(result["Kosten_Euro_pro_Stunde"]) == 47, (
|
assert (
|
||||||
"The length of 'Kosten_Euro_pro_Stunde' should be 48."
|
len(result["Kosten_Euro_pro_Stunde"]) == 47
|
||||||
)
|
), "The length of 'Kosten_Euro_pro_Stunde' should be 48."
|
||||||
assert len(result["akku_soc_pro_stunde"]) == 47, (
|
assert (
|
||||||
"The length of 'akku_soc_pro_stunde' should be 48."
|
len(result["akku_soc_pro_stunde"]) == 47
|
||||||
)
|
), "The length of 'akku_soc_pro_stunde' should be 48."
|
||||||
|
|
||||||
# Verify specific values in the 'Last_Wh_pro_Stunde' array
|
# Verify specific values in the 'Last_Wh_pro_Stunde' array
|
||||||
assert result["Last_Wh_pro_Stunde"][1] == 1527.13, (
|
assert (
|
||||||
"The value at index 1 of 'Last_Wh_pro_Stunde' should be 1527.13."
|
result["Last_Wh_pro_Stunde"][1] == 1527.13
|
||||||
)
|
), "The value at index 1 of 'Last_Wh_pro_Stunde' should be 1527.13."
|
||||||
assert result["Last_Wh_pro_Stunde"][2] == 1468.88, (
|
assert (
|
||||||
"The value at index 2 of 'Last_Wh_pro_Stunde' should be 1468.88."
|
result["Last_Wh_pro_Stunde"][2] == 1468.88
|
||||||
)
|
), "The value at index 2 of 'Last_Wh_pro_Stunde' should be 1468.88."
|
||||||
assert result["Last_Wh_pro_Stunde"][12] == 1132.03, (
|
assert (
|
||||||
"The value at index 12 of 'Last_Wh_pro_Stunde' should be 1132.03."
|
result["Last_Wh_pro_Stunde"][12] == 1132.03
|
||||||
)
|
), "The value at index 12 of 'Last_Wh_pro_Stunde' should be 1132.03."
|
||||||
|
|
||||||
# Verify that the value at index 0 is 'None'
|
# Verify that the value at index 0 is 'None'
|
||||||
# Check that 'Netzeinspeisung_Wh_pro_Stunde' and 'Netzbezug_Wh_pro_Stunde' are consistent
|
# Check that 'Netzeinspeisung_Wh_pro_Stunde' and 'Netzbezug_Wh_pro_Stunde' are consistent
|
||||||
assert result["Netzbezug_Wh_pro_Stunde"][1] == 0, (
|
assert (
|
||||||
"The value at index 1 of 'Netzbezug_Wh_pro_Stunde' should be 0."
|
result["Netzbezug_Wh_pro_Stunde"][1] == 0
|
||||||
)
|
), "The value at index 1 of 'Netzbezug_Wh_pro_Stunde' should be 0."
|
||||||
|
|
||||||
# Verify the total balance
|
# Verify the total balance
|
||||||
assert abs(result["Gesamtbilanz_Euro"] - 1.958185274567674) < 1e-5, (
|
assert (
|
||||||
"Total balance should be 1.958185274567674."
|
abs(result["Gesamtbilanz_Euro"] - 1.958185274567674) < 1e-5
|
||||||
)
|
), "Total balance should be 1.958185274567674."
|
||||||
|
|
||||||
# Check total revenue and total costs
|
# Check total revenue and total costs
|
||||||
assert abs(result["Gesamteinnahmen_Euro"] - 1.168863124510214) < 1e-5, (
|
assert (
|
||||||
"Total revenue should be 1.168863124510214."
|
abs(result["Gesamteinnahmen_Euro"] - 1.168863124510214) < 1e-5
|
||||||
)
|
), "Total revenue should be 1.168863124510214."
|
||||||
assert abs(result["Gesamtkosten_Euro"] - 3.127048399077888) < 1e-5, (
|
assert (
|
||||||
"Total costs should be 3.127048399077888 ."
|
abs(result["Gesamtkosten_Euro"] - 3.127048399077888) < 1e-5
|
||||||
)
|
), "Total costs should be 3.127048399077888 ."
|
||||||
|
|
||||||
# Check the losses
|
# Check the losses
|
||||||
assert abs(result["Gesamt_Verluste"] - 2871.5330639359036) < 1e-5, (
|
assert (
|
||||||
"Total losses should be 2871.5330639359036 ."
|
abs(result["Gesamt_Verluste"] - 2871.5330639359036) < 1e-5
|
||||||
)
|
), "Total losses should be 2871.5330639359036 ."
|
||||||
|
|
||||||
# Check the values in 'akku_soc_pro_stunde'
|
# Check the values in 'akku_soc_pro_stunde'
|
||||||
assert result["akku_soc_pro_stunde"][-1] == 42.151590909090906, (
|
assert (
|
||||||
"The value at index -1 of 'akku_soc_pro_stunde' should be 42.151590909090906."
|
result["akku_soc_pro_stunde"][-1] == 42.151590909090906
|
||||||
)
|
), "The value at index -1 of 'akku_soc_pro_stunde' should be 42.151590909090906."
|
||||||
assert result["akku_soc_pro_stunde"][1] == 60.08659090909091, (
|
assert (
|
||||||
"The value at index 1 of 'akku_soc_pro_stunde' should be 60.08659090909091."
|
result["akku_soc_pro_stunde"][1] == 60.08659090909091
|
||||||
)
|
), "The value at index 1 of 'akku_soc_pro_stunde' should be 60.08659090909091."
|
||||||
|
|
||||||
# Check home appliances
|
# Check home appliances
|
||||||
assert sum(ems.home_appliance.get_load_curve()) == 2000, (
|
assert (
|
||||||
"The sum of 'ems.home_appliance.get_load_curve()' should be 2000."
|
sum(ems.home_appliance.get_load_curve()) == 2000
|
||||||
)
|
), "The sum of 'ems.home_appliance.get_load_curve()' should be 2000."
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
np.nansum(
|
np.nansum(
|
||||||
|
@@ -2,8 +2,8 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from akkudoktoreos.core.ems import (
|
from akkudoktoreos.core.ems import (
|
||||||
EnergyManagement,
|
EnergieManagementSystem,
|
||||||
EnergyManagementParameters,
|
EnergieManagementSystemParameters,
|
||||||
SimulationResult,
|
SimulationResult,
|
||||||
get_ems,
|
get_ems,
|
||||||
)
|
)
|
||||||
@@ -20,8 +20,8 @@ start_hour = 0
|
|||||||
|
|
||||||
# Example initialization of necessary components
|
# Example initialization of necessary components
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def create_ems_instance(devices_eos, config_eos) -> EnergyManagement:
|
def create_ems_instance(devices_eos, config_eos) -> EnergieManagementSystem:
|
||||||
"""Fixture to create an EnergyManagement instance with given test parameters."""
|
"""Fixture to create an EnergieManagementSystem instance with given test parameters."""
|
||||||
# Assure configuration holds the correct values
|
# Assure configuration holds the correct values
|
||||||
config_eos.merge_settings_from_dict(
|
config_eos.merge_settings_from_dict(
|
||||||
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
||||||
@@ -130,7 +130,7 @@ def create_ems_instance(devices_eos, config_eos) -> EnergyManagement:
|
|||||||
# Initialize the energy management system with the respective parameters
|
# Initialize the energy management system with the respective parameters
|
||||||
ems = get_ems()
|
ems = get_ems()
|
||||||
ems.set_parameters(
|
ems.set_parameters(
|
||||||
EnergyManagementParameters(
|
EnergieManagementSystemParameters(
|
||||||
pv_prognose_wh=pv_prognose_wh,
|
pv_prognose_wh=pv_prognose_wh,
|
||||||
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
||||||
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
||||||
@@ -153,7 +153,7 @@ def create_ems_instance(devices_eos, config_eos) -> EnergyManagement:
|
|||||||
|
|
||||||
|
|
||||||
def test_simulation(create_ems_instance):
|
def test_simulation(create_ems_instance):
|
||||||
"""Test the EnergyManagement simulation method."""
|
"""Test the EnergieManagementSystem simulation method."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
|
|
||||||
# Simulate starting from hour 0 (this value can be adjusted)
|
# Simulate starting from hour 0 (this value can be adjusted)
|
||||||
@@ -211,113 +211,113 @@ def test_simulation(create_ems_instance):
|
|||||||
assert key in result, f"The key '{key}' should be present in the result."
|
assert key in result, f"The key '{key}' should be present in the result."
|
||||||
|
|
||||||
# Check the length of the main arrays
|
# Check the length of the main arrays
|
||||||
assert len(result["Last_Wh_pro_Stunde"]) == 48, (
|
assert (
|
||||||
"The length of 'Last_Wh_pro_Stunde' should be 48."
|
len(result["Last_Wh_pro_Stunde"]) == 48
|
||||||
)
|
), "The length of 'Last_Wh_pro_Stunde' should be 48."
|
||||||
assert len(result["Netzeinspeisung_Wh_pro_Stunde"]) == 48, (
|
assert (
|
||||||
"The length of 'Netzeinspeisung_Wh_pro_Stunde' should be 48."
|
len(result["Netzeinspeisung_Wh_pro_Stunde"]) == 48
|
||||||
)
|
), "The length of 'Netzeinspeisung_Wh_pro_Stunde' should be 48."
|
||||||
assert len(result["Netzbezug_Wh_pro_Stunde"]) == 48, (
|
assert (
|
||||||
"The length of 'Netzbezug_Wh_pro_Stunde' should be 48."
|
len(result["Netzbezug_Wh_pro_Stunde"]) == 48
|
||||||
)
|
), "The length of 'Netzbezug_Wh_pro_Stunde' should be 48."
|
||||||
assert len(result["Kosten_Euro_pro_Stunde"]) == 48, (
|
assert (
|
||||||
"The length of 'Kosten_Euro_pro_Stunde' should be 48."
|
len(result["Kosten_Euro_pro_Stunde"]) == 48
|
||||||
)
|
), "The length of 'Kosten_Euro_pro_Stunde' should be 48."
|
||||||
assert len(result["akku_soc_pro_stunde"]) == 48, (
|
assert (
|
||||||
"The length of 'akku_soc_pro_stunde' should be 48."
|
len(result["akku_soc_pro_stunde"]) == 48
|
||||||
)
|
), "The length of 'akku_soc_pro_stunde' should be 48."
|
||||||
|
|
||||||
# Verfify DC and AC Charge Bins
|
# Verfify DC and AC Charge Bins
|
||||||
assert abs(result["akku_soc_pro_stunde"][2] - 44.70681818181818) < 1e-5, (
|
assert (
|
||||||
"'akku_soc_pro_stunde[2]' should be 44.70681818181818."
|
abs(result["akku_soc_pro_stunde"][2] - 44.70681818181818) < 1e-5
|
||||||
)
|
), "'akku_soc_pro_stunde[2]' should be 44.70681818181818."
|
||||||
assert abs(result["akku_soc_pro_stunde"][10] - 10.0) < 1e-5, (
|
assert (
|
||||||
"'akku_soc_pro_stunde[10]' should be 10."
|
abs(result["akku_soc_pro_stunde"][10] - 10.0) < 1e-5
|
||||||
)
|
), "'akku_soc_pro_stunde[10]' should be 10."
|
||||||
|
|
||||||
assert abs(result["Netzeinspeisung_Wh_pro_Stunde"][10] - 3946.93) < 1e-3, (
|
assert (
|
||||||
"'Netzeinspeisung_Wh_pro_Stunde[11]' should be 3946.93."
|
abs(result["Netzeinspeisung_Wh_pro_Stunde"][10] - 3946.93) < 1e-3
|
||||||
)
|
), "'Netzeinspeisung_Wh_pro_Stunde[11]' should be 3946.93."
|
||||||
|
|
||||||
assert abs(result["Netzeinspeisung_Wh_pro_Stunde"][11] - 0.0) < 1e-3, (
|
assert (
|
||||||
"'Netzeinspeisung_Wh_pro_Stunde[11]' should be 0.0."
|
abs(result["Netzeinspeisung_Wh_pro_Stunde"][11] - 0.0) < 1e-3
|
||||||
)
|
), "'Netzeinspeisung_Wh_pro_Stunde[11]' should be 0.0."
|
||||||
|
|
||||||
assert abs(result["akku_soc_pro_stunde"][20] - 10) < 1e-5, (
|
assert (
|
||||||
"'akku_soc_pro_stunde[20]' should be 10."
|
abs(result["akku_soc_pro_stunde"][20] - 10) < 1e-5
|
||||||
)
|
), "'akku_soc_pro_stunde[20]' should be 10."
|
||||||
assert abs(result["Last_Wh_pro_Stunde"][20] - 6050.98) < 1e-3, (
|
assert (
|
||||||
"'Last_Wh_pro_Stunde[20]' should be 6050.98."
|
abs(result["Last_Wh_pro_Stunde"][20] - 6050.98) < 1e-3
|
||||||
)
|
), "'Last_Wh_pro_Stunde[20]' should be 6050.98."
|
||||||
|
|
||||||
print("All tests passed successfully.")
|
print("All tests passed successfully.")
|
||||||
|
|
||||||
|
|
||||||
def test_set_parameters(create_ems_instance):
|
def test_set_parameters(create_ems_instance):
|
||||||
"""Test the set_parameters method of EnergyManagement."""
|
"""Test the set_parameters method of EnergieManagementSystem."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
|
|
||||||
# Check if parameters are set correctly
|
# Check if parameters are set correctly
|
||||||
assert ems.load_energy_array is not None, "load_energy_array should not be None"
|
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.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_price_hourly is not None, "elect_price_hourly should not be None"
|
||||||
assert ems.elect_revenue_per_hour_arr is not None, (
|
assert (
|
||||||
"elect_revenue_per_hour_arr should not be None"
|
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):
|
def test_set_akku_discharge_hours(create_ems_instance):
|
||||||
"""Test the set_akku_discharge_hours method of EnergyManagement."""
|
"""Test the set_akku_discharge_hours method of EnergieManagementSystem."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
discharge_hours = np.full(ems.config.prediction.hours, 1.0)
|
discharge_hours = np.full(ems.config.prediction.hours, 1.0)
|
||||||
ems.set_akku_discharge_hours(discharge_hours)
|
ems.set_akku_discharge_hours(discharge_hours)
|
||||||
assert np.array_equal(ems.battery.discharge_array, discharge_hours), (
|
assert np.array_equal(
|
||||||
"Discharge hours should be set correctly"
|
ems.battery.discharge_array, discharge_hours
|
||||||
)
|
), "Discharge hours should be set correctly"
|
||||||
|
|
||||||
|
|
||||||
def test_set_akku_ac_charge_hours(create_ems_instance):
|
def test_set_akku_ac_charge_hours(create_ems_instance):
|
||||||
"""Test the set_akku_ac_charge_hours method of EnergyManagement."""
|
"""Test the set_akku_ac_charge_hours method of EnergieManagementSystem."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
ac_charge_hours = np.full(ems.config.prediction.hours, 1.0)
|
ac_charge_hours = np.full(ems.config.prediction.hours, 1.0)
|
||||||
ems.set_akku_ac_charge_hours(ac_charge_hours)
|
ems.set_akku_ac_charge_hours(ac_charge_hours)
|
||||||
assert np.array_equal(ems.ac_charge_hours, ac_charge_hours), (
|
assert np.array_equal(
|
||||||
"AC charge hours should be set correctly"
|
ems.ac_charge_hours, ac_charge_hours
|
||||||
)
|
), "AC charge hours should be set correctly"
|
||||||
|
|
||||||
|
|
||||||
def test_set_akku_dc_charge_hours(create_ems_instance):
|
def test_set_akku_dc_charge_hours(create_ems_instance):
|
||||||
"""Test the set_akku_dc_charge_hours method of EnergyManagement."""
|
"""Test the set_akku_dc_charge_hours method of EnergieManagementSystem."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
dc_charge_hours = np.full(ems.config.prediction.hours, 1.0)
|
dc_charge_hours = np.full(ems.config.prediction.hours, 1.0)
|
||||||
ems.set_akku_dc_charge_hours(dc_charge_hours)
|
ems.set_akku_dc_charge_hours(dc_charge_hours)
|
||||||
assert np.array_equal(ems.dc_charge_hours, dc_charge_hours), (
|
assert np.array_equal(
|
||||||
"DC charge hours should be set correctly"
|
ems.dc_charge_hours, dc_charge_hours
|
||||||
)
|
), "DC charge hours should be set correctly"
|
||||||
|
|
||||||
|
|
||||||
def test_set_ev_charge_hours(create_ems_instance):
|
def test_set_ev_charge_hours(create_ems_instance):
|
||||||
"""Test the set_ev_charge_hours method of EnergyManagement."""
|
"""Test the set_ev_charge_hours method of EnergieManagementSystem."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
ev_charge_hours = np.full(ems.config.prediction.hours, 1.0)
|
ev_charge_hours = np.full(ems.config.prediction.hours, 1.0)
|
||||||
ems.set_ev_charge_hours(ev_charge_hours)
|
ems.set_ev_charge_hours(ev_charge_hours)
|
||||||
assert np.array_equal(ems.ev_charge_hours, ev_charge_hours), (
|
assert np.array_equal(
|
||||||
"EV charge hours should be set correctly"
|
ems.ev_charge_hours, ev_charge_hours
|
||||||
)
|
), "EV charge hours should be set correctly"
|
||||||
|
|
||||||
|
|
||||||
def test_reset(create_ems_instance):
|
def test_reset(create_ems_instance):
|
||||||
"""Test the reset method of EnergyManagement."""
|
"""Test the reset method of EnergieManagementSystem."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
ems.reset()
|
ems.reset()
|
||||||
assert ems.ev.current_soc_percentage() == 100, "EV SOC should be reset to initial value"
|
assert ems.ev.current_soc_percentage() == 100, "EV SOC should be reset to initial value"
|
||||||
assert ems.battery.current_soc_percentage() == 80, (
|
assert (
|
||||||
"Battery SOC should be reset to initial value"
|
ems.battery.current_soc_percentage() == 80
|
||||||
)
|
), "Battery SOC should be reset to initial value"
|
||||||
|
|
||||||
|
|
||||||
def test_simulate_start_now(create_ems_instance):
|
def test_simulate_start_now(create_ems_instance):
|
||||||
"""Test the simulate_start_now method of EnergyManagement."""
|
"""Test the simulate_start_now method of EnergieManagementSystem."""
|
||||||
ems = create_ems_instance
|
ems = create_ems_instance
|
||||||
result = ems.simulate_start_now()
|
result = ems.simulate_start_now()
|
||||||
assert result is not None, "Result should not be None"
|
assert result is not None, "Result should not be None"
|
||||||
|
@@ -86,8 +86,7 @@ def test_optimize(
|
|||||||
parameters=input_data, start_hour=start_hour, ngen=ngen
|
parameters=input_data, start_hour=start_hour, ngen=ngen
|
||||||
)
|
)
|
||||||
# Write test output to file, so we can take it as new data on intended change
|
# Write test output to file, so we can take it as new data on intended change
|
||||||
TESTDATA_FILE = DIR_TESTDATA / f"new_{fn_out}"
|
with open(DIR_TESTDATA / f"new_{fn_out}", "w") as f_out:
|
||||||
with TESTDATA_FILE.open("w", encoding="utf-8", newline="\n") as f_out:
|
|
||||||
f_out.write(ergebnis.model_dump_json(indent=4, exclude_unset=True))
|
f_out.write(ergebnis.model_dump_json(indent=4, exclude_unset=True))
|
||||||
|
|
||||||
assert ergebnis.result.Gesamtbilanz_Euro == pytest.approx(
|
assert ergebnis.result.Gesamtbilanz_Euro == pytest.approx(
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -47,19 +46,12 @@ def test_computed_paths(config_eos):
|
|||||||
"general": {
|
"general": {
|
||||||
"data_folder_path": "/base/data",
|
"data_folder_path": "/base/data",
|
||||||
"data_output_subpath": "extra/output",
|
"data_output_subpath": "extra/output",
|
||||||
},
|
"data_cache_subpath": "somewhere/cache",
|
||||||
"cache": {
|
}
|
||||||
"subpath": "somewhere/cache",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert config_eos.general.data_folder_path == Path("/base/data")
|
|
||||||
assert config_eos.general.data_output_path == Path("/base/data/extra/output")
|
assert config_eos.general.data_output_path == Path("/base/data/extra/output")
|
||||||
assert config_eos.cache.path() == Path("/base/data/somewhere/cache")
|
assert config_eos.general.data_cache_path == Path("/base/data/somewhere/cache")
|
||||||
# Check non configurable pathes
|
|
||||||
assert config_eos.package_root_path == Path(__file__).parent.parent.resolve().joinpath(
|
|
||||||
"src/akkudoktoreos"
|
|
||||||
)
|
|
||||||
# reset settings so the config_eos fixture can verify the default paths
|
# reset settings so the config_eos fixture can verify the default paths
|
||||||
config_eos.reset_settings()
|
config_eos.reset_settings()
|
||||||
|
|
||||||
@@ -220,226 +212,3 @@ def test_config_common_settings_timezone_none_when_coordinates_missing():
|
|||||||
assert config_no_latitude.timezone is None
|
assert config_no_latitude.timezone is None
|
||||||
assert config_no_longitude.timezone is None
|
assert config_no_longitude.timezone is None
|
||||||
assert config_no_coords.timezone is None
|
assert config_no_coords.timezone is None
|
||||||
|
|
||||||
|
|
||||||
# Test partial assignments and possible side effects
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"path, value, expected, exception",
|
|
||||||
[
|
|
||||||
# Correct value assignment
|
|
||||||
(
|
|
||||||
"general/latitude",
|
|
||||||
42.0,
|
|
||||||
[("general.latitude", 42.0), ("general.longitude", 13.405)],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
# Correct value assignment (trailing /)
|
|
||||||
(
|
|
||||||
"general/latitude/",
|
|
||||||
41,
|
|
||||||
[("general.latitude", 41.0), ("general.longitude", 13.405)],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
# Correct value assignment (cast)
|
|
||||||
(
|
|
||||||
"general/latitude",
|
|
||||||
"43.0",
|
|
||||||
[("general.latitude", 43.0), ("general.longitude", 13.405)],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
# Invalid value assignment (constraint)
|
|
||||||
(
|
|
||||||
"general/latitude",
|
|
||||||
91.0,
|
|
||||||
[("general.latitude", 52.52), ("general.longitude", 13.405)],
|
|
||||||
ValueError,
|
|
||||||
),
|
|
||||||
# Invalid value assignment (type)
|
|
||||||
(
|
|
||||||
"general/latitude",
|
|
||||||
"test",
|
|
||||||
[("general.latitude", 52.52), ("general.longitude", 13.405)],
|
|
||||||
ValueError,
|
|
||||||
),
|
|
||||||
# Invalid path
|
|
||||||
(
|
|
||||||
"general/latitude/test",
|
|
||||||
"",
|
|
||||||
[("general.latitude", 52.52), ("general.longitude", 13.405)],
|
|
||||||
KeyError,
|
|
||||||
),
|
|
||||||
# Correct value nested assignment
|
|
||||||
(
|
|
||||||
"general",
|
|
||||||
{"latitude": 22},
|
|
||||||
[("general.latitude", 22.0), ("general.longitude", 13.405)],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
# Invalid value nested assignment
|
|
||||||
(
|
|
||||||
"general",
|
|
||||||
{"latitude": "test"},
|
|
||||||
[("general.latitude", 52.52), ("general.longitude", 13.405)],
|
|
||||||
ValueError,
|
|
||||||
),
|
|
||||||
# Correct value for list
|
|
||||||
(
|
|
||||||
"optimization/ev_available_charge_rates_percent/0",
|
|
||||||
0.1,
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"optimization.ev_available_charge_rates_percent",
|
|
||||||
[0.1, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
# Invalid value for list
|
|
||||||
(
|
|
||||||
"optimization/ev_available_charge_rates_percent/0",
|
|
||||||
"invalid",
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"optimization.ev_available_charge_rates_percent",
|
|
||||||
[0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
ValueError,
|
|
||||||
),
|
|
||||||
# Invalid index (out of bound)
|
|
||||||
(
|
|
||||||
"optimization/ev_available_charge_rates_percent/10",
|
|
||||||
0,
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"optimization.ev_available_charge_rates_percent",
|
|
||||||
[0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
IndexError,
|
|
||||||
),
|
|
||||||
# Invalid index (no number)
|
|
||||||
(
|
|
||||||
"optimization/ev_available_charge_rates_percent/test",
|
|
||||||
0,
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"optimization.ev_available_charge_rates_percent",
|
|
||||||
[0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
IndexError,
|
|
||||||
),
|
|
||||||
# Unset value (set None)
|
|
||||||
(
|
|
||||||
"optimization/ev_available_charge_rates_percent",
|
|
||||||
None,
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"optimization.ev_available_charge_rates_percent",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_set_nested_key(path, value, expected, exception, config_eos):
|
|
||||||
if not exception:
|
|
||||||
config_eos.set_config_value(path, value)
|
|
||||||
for expected_path, expected_value in expected:
|
|
||||||
assert eval(f"config_eos.{expected_path}") == expected_value
|
|
||||||
else:
|
|
||||||
with pytest.raises(exception):
|
|
||||||
config_eos.set_config_value(path, value)
|
|
||||||
for expected_path, expected_value in expected:
|
|
||||||
assert eval(f"config_eos.{expected_path}") == expected_value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"path, expected_value, exception",
|
|
||||||
[
|
|
||||||
("general/latitude", 52.52, None),
|
|
||||||
("general/latitude/", 52.52, None),
|
|
||||||
("general/latitude/test", None, KeyError),
|
|
||||||
(
|
|
||||||
"optimization/ev_available_charge_rates_percent/1",
|
|
||||||
0.375,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
("optimization/ev_available_charge_rates_percent/10", 0, IndexError),
|
|
||||||
("optimization/ev_available_charge_rates_percent/test", 0, IndexError),
|
|
||||||
(
|
|
||||||
"optimization/ev_available_charge_rates_percent",
|
|
||||||
[0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_get_nested_key(path, expected_value, exception, config_eos):
|
|
||||||
if not exception:
|
|
||||||
assert config_eos.get_config_value(path) == expected_value
|
|
||||||
else:
|
|
||||||
with pytest.raises(exception):
|
|
||||||
config_eos.get_config_value(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_settings_from_dict_invalid(config_eos):
|
|
||||||
"""Test merging invalid data."""
|
|
||||||
invalid_settings = {
|
|
||||||
"general": {
|
|
||||||
"latitude": "invalid_latitude" # Should be a float
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(Exception): # Pydantic ValidationError expected
|
|
||||||
config_eos.merge_settings_from_dict(invalid_settings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_settings_partial(config_eos):
|
|
||||||
"""Test merging only a subset of settings."""
|
|
||||||
partial_settings: dict[str, dict[str, Union[float, None, str]]] = {
|
|
||||||
"general": {
|
|
||||||
"latitude": 51.1657 # Only latitude is updated
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
config_eos.merge_settings_from_dict(partial_settings)
|
|
||||||
assert config_eos.general.latitude == 51.1657
|
|
||||||
assert config_eos.general.longitude == 13.405 # Should remain unchanged
|
|
||||||
|
|
||||||
partial_settings = {
|
|
||||||
"weather": {
|
|
||||||
"provider": "BrightSky",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
config_eos.merge_settings_from_dict(partial_settings)
|
|
||||||
assert config_eos.weather.provider == "BrightSky"
|
|
||||||
|
|
||||||
partial_settings = {
|
|
||||||
"general": {
|
|
||||||
"latitude": None,
|
|
||||||
},
|
|
||||||
"weather": {
|
|
||||||
"provider": "ClearOutside",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
config_eos.merge_settings_from_dict(partial_settings)
|
|
||||||
assert config_eos.general.latitude is None
|
|
||||||
assert config_eos.weather.provider == "ClearOutside"
|
|
||||||
|
|
||||||
# Assure update keeps same values
|
|
||||||
config_eos.update()
|
|
||||||
assert config_eos.general.latitude is None
|
|
||||||
assert config_eos.weather.provider == "ClearOutside"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_settings_empty(config_eos):
|
|
||||||
"""Test merging an empty dictionary does not change settings."""
|
|
||||||
original_latitude = config_eos.general.latitude
|
|
||||||
|
|
||||||
config_eos.merge_settings_from_dict({}) # No changes
|
|
||||||
|
|
||||||
assert config_eos.general.latitude == original_latitude # Should remain unchanged
|
|
||||||
|
@@ -116,6 +116,7 @@ class TestDataBase:
|
|||||||
def base(self):
|
def base(self):
|
||||||
# Provide default values for configuration
|
# Provide default values for configuration
|
||||||
derived = DerivedBase()
|
derived = DerivedBase()
|
||||||
|
derived.config.update()
|
||||||
return derived
|
return derived
|
||||||
|
|
||||||
def test_get_config_value_key_error(self, base):
|
def test_get_config_value_key_error(self, base):
|
||||||
@@ -562,102 +563,6 @@ class TestDataSequence:
|
|||||||
assert dates == [to_datetime(datetime(2023, 11, 5)), to_datetime(datetime(2023, 11, 6))]
|
assert dates == [to_datetime(datetime(2023, 11, 5)), to_datetime(datetime(2023, 11, 6))]
|
||||||
assert values == [0.8, 0.9]
|
assert values == [0.8, 0.9]
|
||||||
|
|
||||||
def test_to_dataframe_full_data(self, sequence):
|
|
||||||
"""Test conversion of all records to a DataFrame without filtering."""
|
|
||||||
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
|
|
||||||
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
|
|
||||||
record3 = self.create_test_record("2024-01-01T14:00:00Z", 30)
|
|
||||||
sequence.append(record1)
|
|
||||||
sequence.append(record2)
|
|
||||||
sequence.append(record3)
|
|
||||||
|
|
||||||
df = sequence.to_dataframe()
|
|
||||||
|
|
||||||
# Validate DataFrame structure
|
|
||||||
assert isinstance(df, pd.DataFrame)
|
|
||||||
assert not df.empty
|
|
||||||
assert len(df) == 3 # All records should be included
|
|
||||||
assert "data_value" in df.columns
|
|
||||||
|
|
||||||
def test_to_dataframe_with_filter(self, sequence):
|
|
||||||
"""Test filtering records by datetime range."""
|
|
||||||
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
|
|
||||||
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
|
|
||||||
record3 = self.create_test_record("2024-01-01T14:00:00Z", 30)
|
|
||||||
sequence.append(record1)
|
|
||||||
sequence.append(record2)
|
|
||||||
sequence.append(record3)
|
|
||||||
|
|
||||||
start = to_datetime("2024-01-01T12:30:00Z")
|
|
||||||
end = to_datetime("2024-01-01T14:00:00Z")
|
|
||||||
|
|
||||||
df = sequence.to_dataframe(start_datetime=start, end_datetime=end)
|
|
||||||
|
|
||||||
assert isinstance(df, pd.DataFrame)
|
|
||||||
assert not df.empty
|
|
||||||
assert len(df) == 1 # Only one record should match the range
|
|
||||||
assert df.index[0] == pd.Timestamp("2024-01-01T13:00:00Z")
|
|
||||||
|
|
||||||
def test_to_dataframe_no_matching_records(self, sequence):
|
|
||||||
"""Test when no records match the given datetime filter."""
|
|
||||||
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
|
|
||||||
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
|
|
||||||
sequence.append(record1)
|
|
||||||
sequence.append(record2)
|
|
||||||
|
|
||||||
start = to_datetime("2024-01-01T14:00:00Z") # Start time after all records
|
|
||||||
end = to_datetime("2024-01-01T15:00:00Z")
|
|
||||||
|
|
||||||
df = sequence.to_dataframe(start_datetime=start, end_datetime=end)
|
|
||||||
|
|
||||||
assert isinstance(df, pd.DataFrame)
|
|
||||||
assert df.empty # No records should match
|
|
||||||
|
|
||||||
def test_to_dataframe_empty_sequence(self, sequence):
|
|
||||||
"""Test when DataSequence has no records."""
|
|
||||||
sequence = DataSequence(records=[])
|
|
||||||
|
|
||||||
df = sequence.to_dataframe()
|
|
||||||
|
|
||||||
assert isinstance(df, pd.DataFrame)
|
|
||||||
assert df.empty # Should return an empty DataFrame
|
|
||||||
|
|
||||||
def test_to_dataframe_no_start_datetime(self, sequence):
|
|
||||||
"""Test when only end_datetime is given (all past records should be included)."""
|
|
||||||
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
|
|
||||||
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
|
|
||||||
record3 = self.create_test_record("2024-01-01T14:00:00Z", 30)
|
|
||||||
sequence.append(record1)
|
|
||||||
sequence.append(record2)
|
|
||||||
sequence.append(record3)
|
|
||||||
|
|
||||||
end = to_datetime("2024-01-01T13:00:00Z") # Include only first record
|
|
||||||
|
|
||||||
df = sequence.to_dataframe(end_datetime=end)
|
|
||||||
|
|
||||||
assert isinstance(df, pd.DataFrame)
|
|
||||||
assert not df.empty
|
|
||||||
assert len(df) == 1
|
|
||||||
assert df.index[0] == pd.Timestamp("2024-01-01T12:00:00Z")
|
|
||||||
|
|
||||||
def test_to_dataframe_no_end_datetime(self, sequence):
|
|
||||||
"""Test when only start_datetime is given (all future records should be included)."""
|
|
||||||
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
|
|
||||||
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
|
|
||||||
record3 = self.create_test_record("2024-01-01T14:00:00Z", 30)
|
|
||||||
sequence.append(record1)
|
|
||||||
sequence.append(record2)
|
|
||||||
sequence.append(record3)
|
|
||||||
|
|
||||||
start = to_datetime("2024-01-01T13:00:00Z") # Include last two records
|
|
||||||
|
|
||||||
df = sequence.to_dataframe(start_datetime=start)
|
|
||||||
|
|
||||||
assert isinstance(df, pd.DataFrame)
|
|
||||||
assert not df.empty
|
|
||||||
assert len(df) == 2
|
|
||||||
assert df.index[0] == pd.Timestamp("2024-01-01T13:00:00Z")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDataProvider:
|
class TestDataProvider:
|
||||||
# Fixtures and helper functions
|
# Fixtures and helper functions
|
||||||
@@ -683,9 +588,9 @@ class TestDataProvider:
|
|||||||
"""Test that DataProvider enforces singleton behavior."""
|
"""Test that DataProvider enforces singleton behavior."""
|
||||||
instance1 = provider
|
instance1 = provider
|
||||||
instance2 = DerivedDataProvider()
|
instance2 = DerivedDataProvider()
|
||||||
assert instance1 is instance2, (
|
assert (
|
||||||
"Singleton pattern is not enforced; instances are not the same."
|
instance1 is instance2
|
||||||
)
|
), "Singleton pattern is not enforced; instances are not the same."
|
||||||
|
|
||||||
def test_update_method_with_defaults(self, provider, sample_start_datetime, monkeypatch):
|
def test_update_method_with_defaults(self, provider, sample_start_datetime, monkeypatch):
|
||||||
"""Test the `update` method with default parameters."""
|
"""Test the `update` method with default parameters."""
|
||||||
@@ -703,9 +608,9 @@ class TestDataProvider:
|
|||||||
DerivedDataProvider.provider_updated = False
|
DerivedDataProvider.provider_updated = False
|
||||||
provider.update_data(force_enable=True)
|
provider.update_data(force_enable=True)
|
||||||
assert provider.enabled() is False, "Provider should be disabled, but enabled() is True."
|
assert provider.enabled() is False, "Provider should be disabled, but enabled() is True."
|
||||||
assert DerivedDataProvider.provider_updated is True, (
|
assert (
|
||||||
"Provider should have been executed, but was not."
|
DerivedDataProvider.provider_updated is True
|
||||||
)
|
), "Provider should have been executed, but was not."
|
||||||
|
|
||||||
def test_delete_by_datetime(self, provider, sample_start_datetime):
|
def test_delete_by_datetime(self, provider, sample_start_datetime):
|
||||||
"""Test `delete_by_datetime` method for removing records by datetime range."""
|
"""Test `delete_by_datetime` method for removing records by datetime range."""
|
||||||
@@ -720,12 +625,12 @@ class TestDataProvider:
|
|||||||
start_datetime=sample_start_datetime - to_duration("2 hours"),
|
start_datetime=sample_start_datetime - to_duration("2 hours"),
|
||||||
end_datetime=sample_start_datetime + to_duration("2 hours"),
|
end_datetime=sample_start_datetime + to_duration("2 hours"),
|
||||||
)
|
)
|
||||||
assert len(provider.records) == 1, (
|
assert (
|
||||||
"Only one record should remain after deletion by datetime."
|
len(provider.records) == 1
|
||||||
)
|
), "Only one record should remain after deletion by datetime."
|
||||||
assert provider.records[0].date_time == sample_start_datetime - to_duration("3 hours"), (
|
assert provider.records[0].date_time == sample_start_datetime - to_duration(
|
||||||
"Unexpected record remains."
|
"3 hours"
|
||||||
)
|
), "Unexpected record remains."
|
||||||
|
|
||||||
|
|
||||||
class TestDataImportProvider:
|
class TestDataImportProvider:
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -15,7 +14,7 @@ def test_openapi_spec_current(config_eos):
|
|||||||
expected_spec_path = DIR_PROJECT_ROOT / "openapi.json"
|
expected_spec_path = DIR_PROJECT_ROOT / "openapi.json"
|
||||||
new_spec_path = DIR_TESTDATA / "openapi-new.json"
|
new_spec_path = DIR_TESTDATA / "openapi-new.json"
|
||||||
|
|
||||||
with expected_spec_path.open("r", encoding="utf-8", newline=None) as f_expected:
|
with open(expected_spec_path) as f_expected:
|
||||||
expected_spec = json.load(f_expected)
|
expected_spec = json.load(f_expected)
|
||||||
|
|
||||||
# Patch get_config and import within guard to patch global variables within the eos module.
|
# Patch get_config and import within guard to patch global variables within the eos module.
|
||||||
@@ -26,14 +25,12 @@ def test_openapi_spec_current(config_eos):
|
|||||||
from scripts import generate_openapi
|
from scripts import generate_openapi
|
||||||
|
|
||||||
spec = generate_openapi.generate_openapi()
|
spec = generate_openapi.generate_openapi()
|
||||||
spec_str = json.dumps(spec, indent=4, sort_keys=True)
|
|
||||||
|
|
||||||
if os.name == "nt":
|
with open(new_spec_path, "w") as f_new:
|
||||||
spec_str = spec_str.replace("127.0.0.1", "0.0.0.0")
|
json.dump(spec, f_new, indent=4, sort_keys=True)
|
||||||
with new_spec_path.open("w", encoding="utf-8", newline="\n") as f_new:
|
|
||||||
f_new.write(spec_str)
|
|
||||||
|
|
||||||
# Serialize to ensure comparison is consistent
|
# Serialize to ensure comparison is consistent
|
||||||
|
spec_str = json.dumps(spec, indent=4, sort_keys=True)
|
||||||
expected_spec_str = json.dumps(expected_spec, indent=4, sort_keys=True)
|
expected_spec_str = json.dumps(expected_spec, indent=4, sort_keys=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -50,7 +47,7 @@ def test_openapi_md_current(config_eos):
|
|||||||
expected_spec_md_path = DIR_PROJECT_ROOT / "docs" / "_generated" / "openapi.md"
|
expected_spec_md_path = DIR_PROJECT_ROOT / "docs" / "_generated" / "openapi.md"
|
||||||
new_spec_md_path = DIR_TESTDATA / "openapi-new.md"
|
new_spec_md_path = DIR_TESTDATA / "openapi-new.md"
|
||||||
|
|
||||||
with expected_spec_md_path.open("r", encoding="utf-8", newline=None) as f_expected:
|
with open(expected_spec_md_path, encoding="utf8") as f_expected:
|
||||||
expected_spec_md = f_expected.read()
|
expected_spec_md = f_expected.read()
|
||||||
|
|
||||||
# Patch get_config and import within guard to patch global variables within the eos module.
|
# Patch get_config and import within guard to patch global variables within the eos module.
|
||||||
@@ -62,9 +59,7 @@ def test_openapi_md_current(config_eos):
|
|||||||
|
|
||||||
spec_md = generate_openapi_md.generate_openapi_md()
|
spec_md = generate_openapi_md.generate_openapi_md()
|
||||||
|
|
||||||
if os.name == "nt":
|
with open(new_spec_md_path, "w", encoding="utf8") as f_new:
|
||||||
spec_md = spec_md.replace("127.0.0.1", "0.0.0.0")
|
|
||||||
with new_spec_md_path.open("w", encoding="utf-8", newline="\n") as f_new:
|
|
||||||
f_new.write(spec_md)
|
f_new.write(spec_md)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -81,7 +76,7 @@ def test_config_md_current(config_eos):
|
|||||||
expected_config_md_path = DIR_PROJECT_ROOT / "docs" / "_generated" / "config.md"
|
expected_config_md_path = DIR_PROJECT_ROOT / "docs" / "_generated" / "config.md"
|
||||||
new_config_md_path = DIR_TESTDATA / "config-new.md"
|
new_config_md_path = DIR_TESTDATA / "config-new.md"
|
||||||
|
|
||||||
with expected_config_md_path.open("r", encoding="utf-8", newline=None) as f_expected:
|
with open(expected_config_md_path, encoding="utf8") as f_expected:
|
||||||
expected_config_md = f_expected.read()
|
expected_config_md = f_expected.read()
|
||||||
|
|
||||||
# Patch get_config and import within guard to patch global variables within the eos module.
|
# Patch get_config and import within guard to patch global variables within the eos module.
|
||||||
@@ -93,9 +88,7 @@ def test_config_md_current(config_eos):
|
|||||||
|
|
||||||
config_md = generate_config_md.generate_config_md(config_eos)
|
config_md = generate_config_md.generate_config_md(config_eos)
|
||||||
|
|
||||||
if os.name == "nt":
|
with open(new_config_md_path, "w", encoding="utf8") as f_new:
|
||||||
config_md = config_md.replace("127.0.0.1", "0.0.0.0").replace("\\\\", "/")
|
|
||||||
with new_config_md_path.open("w", encoding="utf-8", newline="\n") as f_new:
|
|
||||||
f_new.write(config_md)
|
f_new.write(config_md)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@@ -6,14 +6,13 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import CacheFileStore
|
|
||||||
from akkudoktoreos.core.ems import get_ems
|
from akkudoktoreos.core.ems import get_ems
|
||||||
from akkudoktoreos.core.logging import get_logger
|
|
||||||
from akkudoktoreos.prediction.elecpriceakkudoktor import (
|
from akkudoktoreos.prediction.elecpriceakkudoktor import (
|
||||||
AkkudoktorElecPrice,
|
AkkudoktorElecPrice,
|
||||||
AkkudoktorElecPriceValue,
|
AkkudoktorElecPriceValue,
|
||||||
ElecPriceAkkudoktor,
|
ElecPriceAkkudoktor,
|
||||||
)
|
)
|
||||||
|
from akkudoktoreos.utils.cacheutil import CacheFileStore
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||||
|
|
||||||
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
||||||
@@ -22,8 +21,6 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
|
|||||||
"elecpriceforecast_akkudoktor_1.json"
|
"elecpriceforecast_akkudoktor_1.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def provider(monkeypatch, config_eos):
|
def provider(monkeypatch, config_eos):
|
||||||
@@ -36,9 +33,7 @@ def provider(monkeypatch, config_eos):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_akkudoktor_1_json():
|
def sample_akkudoktor_1_json():
|
||||||
"""Fixture that returns sample forecast data report."""
|
"""Fixture that returns sample forecast data report."""
|
||||||
with FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON.open(
|
with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON, "r") as f_res:
|
||||||
"r", encoding="utf-8", newline=None
|
|
||||||
) as f_res:
|
|
||||||
input_data = json.load(f_res)
|
input_data = json.load(f_res)
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
@@ -90,6 +85,9 @@ def test_request_forecast(mock_get, provider, sample_akkudoktor_1_json):
|
|||||||
mock_response.content = json.dumps(sample_akkudoktor_1_json)
|
mock_response.content = json.dumps(sample_akkudoktor_1_json)
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Preset, as this is usually done by update()
|
||||||
|
provider.config.update()
|
||||||
|
|
||||||
# Test function
|
# Test function
|
||||||
akkudoktor_data = provider._request_forecast()
|
akkudoktor_data = provider._request_forecast()
|
||||||
|
|
||||||
@@ -147,7 +145,6 @@ def test_update_data_with_incomplete_forecast(mock_get, provider):
|
|||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.content = json.dumps(incomplete_data)
|
mock_response.content = json.dumps(incomplete_data)
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
logger.info("The following errors are intentional and part of the test.")
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
provider._update_data(force_update=True)
|
provider._update_data(force_update=True)
|
||||||
|
|
||||||
@@ -175,7 +172,7 @@ def test_request_forecast_status_codes(
|
|||||||
provider._request_forecast()
|
provider._request_forecast()
|
||||||
|
|
||||||
|
|
||||||
@patch("akkudoktoreos.core.cache.CacheFileStore")
|
@patch("akkudoktoreos.utils.cacheutil.CacheFileStore")
|
||||||
def test_cache_integration(mock_cache, provider):
|
def test_cache_integration(mock_cache, provider):
|
||||||
"""Test caching of 8-day electricity price data."""
|
"""Test caching of 8-day electricity price data."""
|
||||||
mock_cache_instance = mock_cache.return_value
|
mock_cache_instance = mock_cache.return_value
|
||||||
@@ -210,7 +207,5 @@ def test_akkudoktor_development_forecast_data(provider):
|
|||||||
|
|
||||||
akkudoktor_data = provider._request_forecast()
|
akkudoktor_data = provider._request_forecast()
|
||||||
|
|
||||||
with FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON.open(
|
with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON, "w") as f_out:
|
||||||
"w", encoding="utf-8", newline="\n"
|
|
||||||
) as f_out:
|
|
||||||
json.dump(akkudoktor_data, f_out, indent=4)
|
json.dump(akkudoktor_data, f_out, indent=4)
|
||||||
|
@@ -33,7 +33,7 @@ def provider(sample_import_1_json, config_eos):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_import_1_json():
|
def sample_import_1_json():
|
||||||
"""Fixture that returns sample forecast data report."""
|
"""Fixture that returns sample forecast data report."""
|
||||||
with FILE_TESTDATA_ELECPRICEIMPORT_1_JSON.open("r", encoding="utf-8", newline=None) as f_res:
|
with open(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON, "r") as f_res:
|
||||||
input_data = json.load(f_res)
|
input_data = json.load(f_res)
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
|
@@ -1,51 +0,0 @@
|
|||||||
import time
|
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
class TestEOSDash:
|
|
||||||
def test_eosdash_started(self, server_setup_for_class, is_system_test):
|
|
||||||
"""Test the EOSdash server is started by EOS server."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eosdash_server = server_setup_for_class["eosdash_server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
timeout = server_setup_for_class["timeout"]
|
|
||||||
|
|
||||||
# Assure EOSdash is up
|
|
||||||
startup = False
|
|
||||||
error = ""
|
|
||||||
for retries in range(int(timeout / 3)):
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
startup = True
|
|
||||||
break
|
|
||||||
error = f"{result.status_code}, {str(result.content)}"
|
|
||||||
except Exception as ex:
|
|
||||||
error = str(ex)
|
|
||||||
time.sleep(3)
|
|
||||||
assert startup, f"Connection to {eosdash_server}/eosdash/health failed: {error}"
|
|
||||||
assert result.json()["status"] == "alive"
|
|
||||||
|
|
||||||
def test_eosdash_proxied_by_eos(self, server_setup_for_class, is_system_test):
|
|
||||||
"""Test the EOSdash server proxied by EOS server."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
timeout = server_setup_for_class["timeout"]
|
|
||||||
|
|
||||||
# Assure EOSdash is up
|
|
||||||
startup = False
|
|
||||||
error = ""
|
|
||||||
for retries in range(int(timeout / 3)):
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{server}/eosdash/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
startup = True
|
|
||||||
break
|
|
||||||
error = f"{result.status_code}, {str(result.content)}"
|
|
||||||
except Exception as ex:
|
|
||||||
error = str(ex)
|
|
||||||
time.sleep(3)
|
|
||||||
assert startup, f"Connection to {server}/eosdash/health failed: {error}"
|
|
||||||
assert result.json()["status"] == "alive"
|
|
@@ -26,8 +26,6 @@ def provider(config_eos):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
config_eos.merge_settings_from_dict(settings)
|
||||||
assert config_eos.load.provider == "LoadAkkudoktor"
|
|
||||||
assert config_eos.load.provider_settings.loadakkudoktor_year_energy == 1000
|
|
||||||
return LoadAkkudoktor()
|
return LoadAkkudoktor()
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
"""Test Module for logging Module."""
|
"""Test Module for logging Module."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -13,7 +13,16 @@ from akkudoktoreos.core.logging import get_logger
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_get_logger_console_logging():
|
@pytest.fixture
|
||||||
|
def clean_up_log_file():
|
||||||
|
"""Fixture to clean up log files after tests."""
|
||||||
|
log_file = "test.log"
|
||||||
|
yield log_file
|
||||||
|
if os.path.exists(log_file):
|
||||||
|
os.remove(log_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_logger_console_logging(clean_up_log_file):
|
||||||
"""Test logger creation with console logging."""
|
"""Test logger creation with console logging."""
|
||||||
logger = get_logger("test_logger", logging_level="DEBUG")
|
logger = get_logger("test_logger", logging_level="DEBUG")
|
||||||
|
|
||||||
@@ -28,10 +37,9 @@ def test_get_logger_console_logging():
|
|||||||
assert isinstance(logger.handlers[0], logging.StreamHandler)
|
assert isinstance(logger.handlers[0], logging.StreamHandler)
|
||||||
|
|
||||||
|
|
||||||
def test_get_logger_file_logging(tmpdir):
|
def test_get_logger_file_logging(clean_up_log_file):
|
||||||
"""Test logger creation with file logging."""
|
"""Test logger creation with file logging."""
|
||||||
log_file = Path(tmpdir).joinpath("test.log")
|
logger = get_logger("test_logger", log_file="test.log", logging_level="WARNING")
|
||||||
logger = get_logger("test_logger", log_file=str(log_file), logging_level="WARNING")
|
|
||||||
|
|
||||||
# Check logger name
|
# Check logger name
|
||||||
assert logger.name == "test_logger"
|
assert logger.name == "test_logger"
|
||||||
@@ -45,10 +53,10 @@ def test_get_logger_file_logging(tmpdir):
|
|||||||
assert isinstance(logger.handlers[1], RotatingFileHandler)
|
assert isinstance(logger.handlers[1], RotatingFileHandler)
|
||||||
|
|
||||||
# Check file existence
|
# Check file existence
|
||||||
assert log_file.exists()
|
assert os.path.exists("test.log")
|
||||||
|
|
||||||
|
|
||||||
def test_get_logger_no_file_logging():
|
def test_get_logger_no_file_logging(clean_up_log_file):
|
||||||
"""Test logger creation without file logging."""
|
"""Test logger creation without file logging."""
|
||||||
logger = get_logger("test_logger")
|
logger = get_logger("test_logger")
|
||||||
|
|
||||||
@@ -63,7 +71,7 @@ def test_get_logger_no_file_logging():
|
|||||||
assert isinstance(logger.handlers[0], logging.StreamHandler)
|
assert isinstance(logger.handlers[0], logging.StreamHandler)
|
||||||
|
|
||||||
|
|
||||||
def test_get_logger_with_invalid_level():
|
def test_get_logger_with_invalid_level(clean_up_log_file):
|
||||||
"""Test logger creation with an invalid logging level."""
|
"""Test logger creation with an invalid logging level."""
|
||||||
with pytest.raises(ValueError, match="Unknown loggin level: INVALID"):
|
with pytest.raises(ValueError, match="Unknown loggin level: INVALID"):
|
||||||
logger = get_logger("test_logger", logging_level="INVALID")
|
logger = get_logger("test_logger", logging_level="INVALID")
|
||||||
|
@@ -151,9 +151,9 @@ class TestPredictionProvider:
|
|||||||
"""Test that PredictionProvider enforces singleton behavior."""
|
"""Test that PredictionProvider enforces singleton behavior."""
|
||||||
instance1 = provider
|
instance1 = provider
|
||||||
instance2 = DerivedPredictionProvider()
|
instance2 = DerivedPredictionProvider()
|
||||||
assert instance1 is instance2, (
|
assert (
|
||||||
"Singleton pattern is not enforced; instances are not the same."
|
instance1 is instance2
|
||||||
)
|
), "Singleton pattern is not enforced; instances are not the same."
|
||||||
|
|
||||||
def test_update_computed_fields(self, provider, sample_start_datetime):
|
def test_update_computed_fields(self, provider, sample_start_datetime):
|
||||||
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
|
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
|
||||||
@@ -169,12 +169,12 @@ class TestPredictionProvider:
|
|||||||
provider.config.prediction.historic_hours * 3600
|
provider.config.prediction.historic_hours * 3600
|
||||||
)
|
)
|
||||||
|
|
||||||
assert provider.end_datetime == expected_end_datetime, (
|
assert (
|
||||||
"End datetime is not calculated correctly."
|
provider.end_datetime == expected_end_datetime
|
||||||
)
|
), "End datetime is not calculated correctly."
|
||||||
assert provider.keep_datetime == expected_keep_datetime, (
|
assert (
|
||||||
"Keep datetime is not calculated correctly."
|
provider.keep_datetime == expected_keep_datetime
|
||||||
)
|
), "Keep datetime is not calculated correctly."
|
||||||
|
|
||||||
def test_update_method_with_defaults(
|
def test_update_method_with_defaults(
|
||||||
self, provider, sample_start_datetime, config_eos, monkeypatch
|
self, provider, sample_start_datetime, config_eos, monkeypatch
|
||||||
@@ -201,17 +201,17 @@ class TestPredictionProvider:
|
|||||||
def test_update_method_force_enable(self, provider, monkeypatch):
|
def test_update_method_force_enable(self, provider, monkeypatch):
|
||||||
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
|
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
|
||||||
# Preset values that are needed by update
|
# Preset values that are needed by update
|
||||||
monkeypatch.setenv("EOS_GENERAL__LATITUDE", "37.7749")
|
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "37.7749")
|
||||||
monkeypatch.setenv("EOS_GENERAL__LONGITUDE", "-122.4194")
|
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "-122.4194")
|
||||||
|
|
||||||
# Override enabled to return False for this test
|
# Override enabled to return False for this test
|
||||||
DerivedPredictionProvider.provider_enabled = False
|
DerivedPredictionProvider.provider_enabled = False
|
||||||
DerivedPredictionProvider.provider_updated = False
|
DerivedPredictionProvider.provider_updated = False
|
||||||
provider.update_data(force_enable=True)
|
provider.update_data(force_enable=True)
|
||||||
assert provider.enabled() is False, "Provider should be disabled, but enabled() is True."
|
assert provider.enabled() is False, "Provider should be disabled, but enabled() is True."
|
||||||
assert DerivedPredictionProvider.provider_updated is True, (
|
assert (
|
||||||
"Provider should have been executed, but was not."
|
DerivedPredictionProvider.provider_updated is True
|
||||||
)
|
), "Provider should have been executed, but was not."
|
||||||
|
|
||||||
def test_delete_by_datetime(self, provider, sample_start_datetime):
|
def test_delete_by_datetime(self, provider, sample_start_datetime):
|
||||||
"""Test `delete_by_datetime` method for removing records by datetime range."""
|
"""Test `delete_by_datetime` method for removing records by datetime range."""
|
||||||
@@ -226,12 +226,12 @@ class TestPredictionProvider:
|
|||||||
start_datetime=sample_start_datetime - to_duration("2 hours"),
|
start_datetime=sample_start_datetime - to_duration("2 hours"),
|
||||||
end_datetime=sample_start_datetime + to_duration("2 hours"),
|
end_datetime=sample_start_datetime + to_duration("2 hours"),
|
||||||
)
|
)
|
||||||
assert len(provider.records) == 1, (
|
assert (
|
||||||
"Only one record should remain after deletion by datetime."
|
len(provider.records) == 1
|
||||||
)
|
), "Only one record should remain after deletion by datetime."
|
||||||
assert provider.records[0].date_time == sample_start_datetime - to_duration("3 hours"), (
|
assert provider.records[0].date_time == sample_start_datetime - to_duration(
|
||||||
"Unexpected record remains."
|
"3 hours"
|
||||||
)
|
), "Unexpected record remains."
|
||||||
|
|
||||||
|
|
||||||
class TestPredictionContainer:
|
class TestPredictionContainer:
|
||||||
|
@@ -5,7 +5,6 @@ from unittest.mock import Mock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from akkudoktoreos.core.ems import get_ems
|
from akkudoktoreos.core.ems import get_ems
|
||||||
from akkudoktoreos.core.logging import get_logger
|
|
||||||
from akkudoktoreos.prediction.prediction import get_prediction
|
from akkudoktoreos.prediction.prediction import get_prediction
|
||||||
from akkudoktoreos.prediction.pvforecastakkudoktor import (
|
from akkudoktoreos.prediction.pvforecastakkudoktor import (
|
||||||
AkkudoktorForecastHorizon,
|
AkkudoktorForecastHorizon,
|
||||||
@@ -21,8 +20,6 @@ DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
|||||||
FILE_TESTDATA_PV_FORECAST_INPUT_1 = DIR_TESTDATA.joinpath("pv_forecast_input_1.json")
|
FILE_TESTDATA_PV_FORECAST_INPUT_1 = DIR_TESTDATA.joinpath("pv_forecast_input_1.json")
|
||||||
FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1.txt")
|
FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1.txt")
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_settings(config_eos):
|
def sample_settings(config_eos):
|
||||||
@@ -80,7 +77,7 @@ def sample_settings(config_eos):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_forecast_data():
|
def sample_forecast_data():
|
||||||
"""Fixture that returns sample forecast data converted to pydantic model."""
|
"""Fixture that returns sample forecast data converted to pydantic model."""
|
||||||
with FILE_TESTDATA_PV_FORECAST_INPUT_1.open("r", encoding="utf-8", newline=None) as f_in:
|
with open(FILE_TESTDATA_PV_FORECAST_INPUT_1, "r", encoding="utf8") as f_in:
|
||||||
input_data = f_in.read()
|
input_data = f_in.read()
|
||||||
return PVForecastAkkudoktor._validate_data(input_data)
|
return PVForecastAkkudoktor._validate_data(input_data)
|
||||||
|
|
||||||
@@ -88,7 +85,7 @@ def sample_forecast_data():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_forecast_data_raw():
|
def sample_forecast_data_raw():
|
||||||
"""Fixture that returns raw sample forecast data."""
|
"""Fixture that returns raw sample forecast data."""
|
||||||
with FILE_TESTDATA_PV_FORECAST_INPUT_1.open("r", encoding="utf-8", newline=None) as f_in:
|
with open(FILE_TESTDATA_PV_FORECAST_INPUT_1, "r", encoding="utf8") as f_in:
|
||||||
input_data = f_in.read()
|
input_data = f_in.read()
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
@@ -96,7 +93,7 @@ def sample_forecast_data_raw():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_forecast_report():
|
def sample_forecast_report():
|
||||||
"""Fixture that returns sample forecast data report."""
|
"""Fixture that returns sample forecast data report."""
|
||||||
with FILE_TESTDATA_PV_FORECAST_RESULT_1.open("r", encoding="utf-8", newline=None) as f_res:
|
with open(FILE_TESTDATA_PV_FORECAST_RESULT_1, "r", encoding="utf8") as f_res:
|
||||||
input_data = f_res.read()
|
input_data = f_res.read()
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
@@ -226,7 +223,6 @@ def test_pvforecast_akkudoktor_data_record():
|
|||||||
|
|
||||||
def test_pvforecast_akkudoktor_validate_data(provider_empty_instance, sample_forecast_data_raw):
|
def test_pvforecast_akkudoktor_validate_data(provider_empty_instance, sample_forecast_data_raw):
|
||||||
"""Test validation of PV forecast data on sample data."""
|
"""Test validation of PV forecast data on sample data."""
|
||||||
logger.info("The following errors are intentional and part of the test.")
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="Field: meta\nError: Field required\nType: missing\nField: values\nError: Field required\nType: missing\n",
|
match="Field: meta\nError: Field required\nType: missing\nField: values\nError: Field required\nType: missing\n",
|
||||||
|
@@ -33,7 +33,7 @@ def provider(sample_import_1_json, config_eos):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_import_1_json():
|
def sample_import_1_json():
|
||||||
"""Fixture that returns sample forecast data report."""
|
"""Fixture that returns sample forecast data report."""
|
||||||
with FILE_TESTDATA_PVFORECASTIMPORT_1_JSON.open("r", encoding="utf-8", newline=None) as f_res:
|
with open(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON, "r") as f_res:
|
||||||
input_data = json.load(f_res)
|
input_data = json.load(f_res)
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
|
@@ -1,444 +1,13 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import time
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
import pytest
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from akkudoktoreos.server.server import get_default_host
|
|
||||||
|
|
||||||
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
def test_server(server, config_eos):
|
||||||
|
"""Test the server."""
|
||||||
|
# validate correct path in server
|
||||||
|
assert config_eos.general.data_folder_path is not None
|
||||||
|
assert config_eos.general.data_folder_path.is_dir()
|
||||||
|
|
||||||
FILE_TESTDATA_EOSSERVER_CONFIG_1 = DIR_TESTDATA.joinpath("eosserver_config_1.json")
|
result = requests.get(f"{server}/v1/config")
|
||||||
|
assert result.status_code == HTTPStatus.OK
|
||||||
|
|
||||||
class TestServer:
|
|
||||||
def test_server_setup_for_class(self, server_setup_for_class):
|
|
||||||
"""Ensure server is started."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/config")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Get testing config
|
|
||||||
config_json = result.json()
|
|
||||||
config_folder_path = Path(config_json["general"]["config_folder_path"])
|
|
||||||
config_file_path = Path(config_json["general"]["config_file_path"])
|
|
||||||
data_folder_path = Path(config_json["general"]["data_folder_path"])
|
|
||||||
data_ouput_path = Path(config_json["general"]["data_output_path"])
|
|
||||||
# Assure we are working in test environment
|
|
||||||
assert str(config_folder_path).startswith(eos_dir)
|
|
||||||
assert str(config_file_path).startswith(eos_dir)
|
|
||||||
assert str(data_folder_path).startswith(eos_dir)
|
|
||||||
assert str(data_ouput_path).startswith(eos_dir)
|
|
||||||
|
|
||||||
def test_prediction_brightsky(self, server_setup_for_class, is_system_test):
|
|
||||||
"""Test weather prediction by BrightSky."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/config")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Get testing config
|
|
||||||
config_json = result.json()
|
|
||||||
config_folder_path = Path(config_json["general"]["config_folder_path"])
|
|
||||||
# Assure we are working in test environment
|
|
||||||
assert str(config_folder_path).startswith(eos_dir)
|
|
||||||
|
|
||||||
result = requests.put(f"{server}/v1/config/weather/provider", json="BrightSky")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Assure prediction is enabled
|
|
||||||
result = requests.get(f"{server}/v1/prediction/providers?enabled=true")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
providers = result.json()
|
|
||||||
assert "BrightSky" in providers
|
|
||||||
|
|
||||||
if is_system_test:
|
|
||||||
result = requests.post(f"{server}/v1/prediction/update/BrightSky")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/prediction/series?key=weather_temp_air")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
data = result.json()
|
|
||||||
assert len(data["data"]) > 24
|
|
||||||
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_prediction_clearoutside(self, server_setup_for_class, is_system_test):
|
|
||||||
"""Test weather prediction by ClearOutside."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
|
|
||||||
result = requests.put(f"{server}/v1/config/weather/provider", json="ClearOutside")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Assure prediction is enabled
|
|
||||||
result = requests.get(f"{server}/v1/prediction/providers?enabled=true")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
providers = result.json()
|
|
||||||
assert "ClearOutside" in providers
|
|
||||||
|
|
||||||
if is_system_test:
|
|
||||||
result = requests.post(f"{server}/v1/prediction/update/ClearOutside")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/prediction/series?key=weather_temp_air")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
data = result.json()
|
|
||||||
assert len(data["data"]) > 24
|
|
||||||
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_prediction_pvforecastakkudoktor(self, server_setup_for_class, is_system_test):
|
|
||||||
"""Test PV prediction by PVForecastAkkudoktor."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
|
|
||||||
# Reset config
|
|
||||||
with FILE_TESTDATA_EOSSERVER_CONFIG_1.open("r", encoding="utf-8", newline=None) as fd:
|
|
||||||
config = json.load(fd)
|
|
||||||
config["pvforecast"]["provider"] = "PVForecastAkkudoktor"
|
|
||||||
result = requests.put(f"{server}/v1/config", json=config)
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Assure prediction is enabled
|
|
||||||
result = requests.get(f"{server}/v1/prediction/providers?enabled=true")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
providers = result.json()
|
|
||||||
assert "PVForecastAkkudoktor" in providers
|
|
||||||
|
|
||||||
if is_system_test:
|
|
||||||
result = requests.post(f"{server}/v1/prediction/update/PVForecastAkkudoktor")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/prediction/series?key=pvforecast_ac_power")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
data = result.json()
|
|
||||||
assert len(data["data"]) > 24
|
|
||||||
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_prediction_elecpriceakkudoktor(self, server_setup_for_class, is_system_test):
|
|
||||||
"""Test electricity price prediction by ElecPriceImport."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
|
|
||||||
# Reset config
|
|
||||||
with FILE_TESTDATA_EOSSERVER_CONFIG_1.open("r", encoding="utf-8", newline=None) as fd:
|
|
||||||
config = json.load(fd)
|
|
||||||
config["elecprice"]["provider"] = "ElecPriceAkkudoktor"
|
|
||||||
result = requests.put(f"{server}/v1/config", json=config)
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Assure prediction is enabled
|
|
||||||
result = requests.get(f"{server}/v1/prediction/providers?enabled=true")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
providers = result.json()
|
|
||||||
assert "ElecPriceAkkudoktor" in providers
|
|
||||||
|
|
||||||
if is_system_test:
|
|
||||||
result = requests.post(f"{server}/v1/prediction/update/ElecPriceAkkudoktor")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/prediction/series?key=elecprice_marketprice_wh")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
data = result.json()
|
|
||||||
assert len(data["data"]) > 24
|
|
||||||
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_prediction_loadakkudoktor(self, server_setup_for_class, is_system_test):
|
|
||||||
"""Test load prediction by LoadAkkudoktor."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
|
|
||||||
result = requests.put(f"{server}/v1/config/load/provider", json="LoadAkkudoktor")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Assure prediction is enabled
|
|
||||||
result = requests.get(f"{server}/v1/prediction/providers?enabled=true")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
providers = result.json()
|
|
||||||
assert "LoadAkkudoktor" in providers
|
|
||||||
|
|
||||||
if is_system_test:
|
|
||||||
result = requests.post(f"{server}/v1/prediction/update/LoadAkkudoktor")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/prediction/series?key=load_mean")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
data = result.json()
|
|
||||||
assert len(data["data"]) > 24
|
|
||||||
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_admin_cache(self, server_setup_for_class, is_system_test):
|
|
||||||
"""Test whether cache is reconstructed from cached files."""
|
|
||||||
server = server_setup_for_class["server"]
|
|
||||||
eos_dir = server_setup_for_class["eos_dir"]
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/admin/cache")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
cache = result.json()
|
|
||||||
|
|
||||||
if is_system_test:
|
|
||||||
# There should be some cache data
|
|
||||||
assert cache != {}
|
|
||||||
|
|
||||||
# Save cache
|
|
||||||
result = requests.post(f"{server}/v1/admin/cache/save")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
cache_saved = result.json()
|
|
||||||
assert cache_saved == cache
|
|
||||||
|
|
||||||
# Clear cache - should clear nothing as all cache files expire in the future
|
|
||||||
result = requests.post(f"{server}/v1/admin/cache/clear")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
cache_cleared = result.json()
|
|
||||||
assert cache_cleared == cache
|
|
||||||
|
|
||||||
# Force clear cache
|
|
||||||
result = requests.post(f"{server}/v1/admin/cache/clear?clear_all=true")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
cache_cleared = result.json()
|
|
||||||
assert cache_cleared == {}
|
|
||||||
|
|
||||||
# Try to load already deleted cache entries
|
|
||||||
result = requests.post(f"{server}/v1/admin/cache/load")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
cache_loaded = result.json()
|
|
||||||
assert cache_loaded == {}
|
|
||||||
|
|
||||||
# Cache should still be empty
|
|
||||||
result = requests.get(f"{server}/v1/admin/cache")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
cache = result.json()
|
|
||||||
assert cache == {}
|
|
||||||
|
|
||||||
|
|
||||||
class TestServerStartStop:
|
|
||||||
def test_server_start_eosdash(self, tmpdir):
|
|
||||||
"""Test the EOSdash server startup from EOS."""
|
|
||||||
# Do not use any fixture as this will make pytest the owner of the EOSdash port.
|
|
||||||
host = get_default_host()
|
|
||||||
if os.name == "nt":
|
|
||||||
# Windows does not provide SIGKILL
|
|
||||||
sigkill = signal.SIGTERM # type: ignore[attr-defined,unused-ignore]
|
|
||||||
else:
|
|
||||||
sigkill = signal.SIGKILL # type: ignore
|
|
||||||
port = 8503
|
|
||||||
eosdash_port = 8504
|
|
||||||
timeout = 120
|
|
||||||
|
|
||||||
server = f"http://{host}:{port}"
|
|
||||||
eosdash_server = f"http://{host}:{eosdash_port}"
|
|
||||||
eos_dir = str(tmpdir)
|
|
||||||
|
|
||||||
# Cleanup any EOSdash process left.
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
pid = result.json()["pid"]
|
|
||||||
os.kill(pid, sigkill)
|
|
||||||
time.sleep(1)
|
|
||||||
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
|
|
||||||
assert result.status_code != HTTPStatus.OK
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Wait for EOSdash port to be freed
|
|
||||||
process_info: list[dict] = []
|
|
||||||
for retries in range(int(timeout / 3)):
|
|
||||||
process_info = []
|
|
||||||
pids: list[int] = []
|
|
||||||
for conn in psutil.net_connections(kind="inet"):
|
|
||||||
if conn.laddr.port == eosdash_port:
|
|
||||||
if conn.pid not in pids:
|
|
||||||
# Get fresh process info
|
|
||||||
process = psutil.Process(conn.pid)
|
|
||||||
pids.append(conn.pid)
|
|
||||||
process_info.append(process.as_dict(attrs=["pid", "cmdline"]))
|
|
||||||
if len(process_info) == 0:
|
|
||||||
break
|
|
||||||
time.sleep(3)
|
|
||||||
assert len(process_info) == 0
|
|
||||||
|
|
||||||
# Import after test setup to prevent creation of config file before test
|
|
||||||
from akkudoktoreos.server.eos import start_eosdash
|
|
||||||
|
|
||||||
process = start_eosdash(
|
|
||||||
host=host,
|
|
||||||
port=eosdash_port,
|
|
||||||
eos_host=host,
|
|
||||||
eos_port=port,
|
|
||||||
log_level="debug",
|
|
||||||
access_log=False,
|
|
||||||
reload=False,
|
|
||||||
eos_dir=eos_dir,
|
|
||||||
eos_config_dir=eos_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assure EOSdash is up
|
|
||||||
startup = False
|
|
||||||
error = ""
|
|
||||||
for retries in range(int(timeout / 3)):
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
startup = True
|
|
||||||
break
|
|
||||||
error = f"{result.status_code}, {str(result.content)}"
|
|
||||||
except Exception as ex:
|
|
||||||
error = str(ex)
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
assert startup, f"Connection to {eosdash_server}/eosdash/health failed: {error}"
|
|
||||||
assert result.json()["status"] == "alive"
|
|
||||||
|
|
||||||
# Shutdown eosdash
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
pid = result.json()["pid"]
|
|
||||||
os.kill(pid, signal.SIGTERM)
|
|
||||||
time.sleep(1)
|
|
||||||
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
|
|
||||||
assert result.status_code != HTTPStatus.OK
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="Server restart not supported on Windows")
|
|
||||||
def test_server_restart(self, server_setup_for_function, is_system_test):
|
|
||||||
"""Test server restart."""
|
|
||||||
server = server_setup_for_function["server"]
|
|
||||||
eos_dir = server_setup_for_function["eos_dir"]
|
|
||||||
timeout = server_setup_for_function["timeout"]
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/config")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Get testing config
|
|
||||||
config_json = result.json()
|
|
||||||
config_folder_path = Path(config_json["general"]["config_folder_path"])
|
|
||||||
config_file_path = Path(config_json["general"]["config_file_path"])
|
|
||||||
data_folder_path = Path(config_json["general"]["data_folder_path"])
|
|
||||||
data_ouput_path = Path(config_json["general"]["data_output_path"])
|
|
||||||
cache_file_path = data_folder_path.joinpath(config_json["cache"]["subpath"]).joinpath(
|
|
||||||
"cachefilestore.json"
|
|
||||||
)
|
|
||||||
# Assure we are working in test environment
|
|
||||||
assert str(config_folder_path).startswith(eos_dir)
|
|
||||||
assert str(config_file_path).startswith(eos_dir)
|
|
||||||
assert str(data_folder_path).startswith(eos_dir)
|
|
||||||
assert str(data_ouput_path).startswith(eos_dir)
|
|
||||||
|
|
||||||
if is_system_test:
|
|
||||||
# Prepare cache entry and get cached data
|
|
||||||
result = requests.put(f"{server}/v1/config/weather/provider", json="BrightSky")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
result = requests.post(f"{server}/v1/prediction/update/BrightSky")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/prediction/series?key=weather_temp_air")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
data = result.json()
|
|
||||||
assert data["data"] != {}
|
|
||||||
|
|
||||||
result = requests.put(f"{server}/v1/config/file")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
|
|
||||||
# Save cache
|
|
||||||
result = requests.post(f"{server}/v1/admin/cache/save")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
cache = result.json()
|
|
||||||
|
|
||||||
assert cache_file_path.exists()
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/admin/cache")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
cache = result.json()
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/health")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
pid = result.json()["pid"]
|
|
||||||
|
|
||||||
result = requests.post(f"{server}/v1/admin/server/restart")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
assert "Restarting EOS.." in result.json()["message"]
|
|
||||||
new_pid = result.json()["pid"]
|
|
||||||
|
|
||||||
# Wait for server to shut down
|
|
||||||
for retries in range(10):
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{server}/v1/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
pid = result.json()["pid"]
|
|
||||||
if pid == new_pid:
|
|
||||||
# Already started
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
except Exception as ex:
|
|
||||||
break
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
# Assure EOS is up again
|
|
||||||
startup = False
|
|
||||||
error = ""
|
|
||||||
for retries in range(int(timeout / 3)):
|
|
||||||
try:
|
|
||||||
result = requests.get(f"{server}/v1/health", timeout=2)
|
|
||||||
if result.status_code == HTTPStatus.OK:
|
|
||||||
startup = True
|
|
||||||
break
|
|
||||||
error = f"{result.status_code}, {str(result.content)}"
|
|
||||||
except Exception as ex:
|
|
||||||
error = str(ex)
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
assert startup, f"Connection to {server}/v1/health failed: {error}"
|
|
||||||
assert result.json()["status"] == "alive"
|
|
||||||
pid = result.json()["pid"]
|
|
||||||
assert pid == new_pid
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/admin/cache")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
new_cache = result.json()
|
|
||||||
|
|
||||||
assert cache.items() <= new_cache.items()
|
|
||||||
|
|
||||||
if is_system_test:
|
|
||||||
result = requests.get(f"{server}/v1/config")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
assert result.json()["weather"]["provider"] == "BrightSky"
|
|
||||||
|
|
||||||
# Wait for initialisation task to have finished
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/prediction/series?key=weather_temp_air")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
assert result.json() == data
|
|
||||||
|
|
||||||
# Shutdown the newly created server
|
|
||||||
result = requests.post(f"{server}/v1/admin/server/shutdown")
|
|
||||||
assert result.status_code == HTTPStatus.OK
|
|
||||||
assert "Stopping EOS.." in result.json()["message"]
|
|
||||||
new_pid = result.json()["pid"]
|
|
||||||
|
@@ -5,9 +5,9 @@ from unittest.mock import Mock, patch
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import CacheFileStore
|
|
||||||
from akkudoktoreos.core.ems import get_ems
|
from akkudoktoreos.core.ems import get_ems
|
||||||
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
||||||
|
from akkudoktoreos.utils.cacheutil import CacheFileStore
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||||
|
|
||||||
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
||||||
@@ -20,15 +20,15 @@ FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_b
|
|||||||
def provider(monkeypatch):
|
def provider(monkeypatch):
|
||||||
"""Fixture to create a WeatherProvider instance."""
|
"""Fixture to create a WeatherProvider instance."""
|
||||||
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "BrightSky")
|
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "BrightSky")
|
||||||
monkeypatch.setenv("EOS_GENERAL__LATITUDE", "50.0")
|
monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "50.0")
|
||||||
monkeypatch.setenv("EOS_GENERAL__LONGITUDE", "10.0")
|
monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "10.0")
|
||||||
return WeatherBrightSky()
|
return WeatherBrightSky()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_brightsky_1_json():
|
def sample_brightsky_1_json():
|
||||||
"""Fixture that returns sample forecast data report."""
|
"""Fixture that returns sample forecast data report."""
|
||||||
with FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON.open("r", encoding="utf-8", newline=None) as f_res:
|
with open(FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON, "r") as f_res:
|
||||||
input_data = json.load(f_res)
|
input_data = json.load(f_res)
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ def sample_brightsky_1_json():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_brightsky_2_json():
|
def sample_brightsky_2_json():
|
||||||
"""Fixture that returns sample forecast data report."""
|
"""Fixture that returns sample forecast data report."""
|
||||||
with FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON.open("r", encoding="utf-8", newline=None) as f_res:
|
with open(FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON, "r") as f_res:
|
||||||
input_data = json.load(f_res)
|
input_data = json.load(f_res)
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
@@ -107,6 +107,9 @@ def test_request_forecast(mock_get, provider, sample_brightsky_1_json):
|
|||||||
mock_response.content = json.dumps(sample_brightsky_1_json)
|
mock_response.content = json.dumps(sample_brightsky_1_json)
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Preset, as this is usually done by update()
|
||||||
|
provider.config.update()
|
||||||
|
|
||||||
# Test function
|
# Test function
|
||||||
brightsky_data = provider._request_forecast()
|
brightsky_data = provider._request_forecast()
|
||||||
|
|
||||||
@@ -173,18 +176,15 @@ def test_update_data(mock_get, provider, sample_brightsky_1_json, cache_store):
|
|||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_brightsky_development_forecast_data(provider, config_eos, is_system_test):
|
@pytest.mark.skip(reason="For development only")
|
||||||
|
def test_brightsky_development_forecast_data(provider):
|
||||||
"""Fetch data from real BrightSky server."""
|
"""Fetch data from real BrightSky server."""
|
||||||
if not is_system_test:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Preset, as this is usually done by update_data()
|
# Preset, as this is usually done by update_data()
|
||||||
ems_eos = get_ems()
|
provider.start_datetime = to_datetime("2024-10-26 00:00:00")
|
||||||
ems_eos.set_start_datetime(to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin"))
|
provider.latitude = 50.0
|
||||||
config_eos.general.latitude = 50.0
|
provider.longitude = 10.0
|
||||||
config_eos.general.longitude = 10.0
|
|
||||||
|
|
||||||
brightsky_data = provider._request_forecast()
|
brightsky_data = provider._request_forecast()
|
||||||
|
|
||||||
with FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON.open("w", encoding="utf-8", newline="\n") as f_out:
|
with open(FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON, "w") as f_out:
|
||||||
json.dump(brightsky_data, f_out, indent=4)
|
json.dump(brightsky_data, f_out, indent=4)
|
||||||
|
@@ -9,9 +9,9 @@ import pvlib
|
|||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from akkudoktoreos.core.cache import CacheFileStore
|
|
||||||
from akkudoktoreos.core.ems import get_ems
|
from akkudoktoreos.core.ems import get_ems
|
||||||
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||||
|
from akkudoktoreos.utils.cacheutil import CacheFileStore
|
||||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
||||||
|
|
||||||
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
||||||
@@ -39,9 +39,7 @@ def provider(config_eos):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_clearout_1_html():
|
def sample_clearout_1_html():
|
||||||
"""Fixture that returns sample forecast data report."""
|
"""Fixture that returns sample forecast data report."""
|
||||||
with FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_HTML.open(
|
with open(FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_HTML, "r") as f_res:
|
||||||
"r", encoding="utf-8", newline=None
|
|
||||||
) as f_res:
|
|
||||||
input_data = f_res.read()
|
input_data = f_res.read()
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
@@ -49,7 +47,7 @@ def sample_clearout_1_html():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_clearout_1_data():
|
def sample_clearout_1_data():
|
||||||
"""Fixture that returns sample forecast data."""
|
"""Fixture that returns sample forecast data."""
|
||||||
with FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA.open("r", encoding="utf-8", newline=None) as f_in:
|
with open(FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA, "r", encoding="utf8") as f_in:
|
||||||
json_str = f_in.read()
|
json_str = f_in.read()
|
||||||
data = WeatherClearOutside.from_json(json_str)
|
data = WeatherClearOutside.from_json(json_str)
|
||||||
return data
|
return data
|
||||||
@@ -222,9 +220,7 @@ def test_development_forecast_data(mock_get, provider, sample_clearout_1_html):
|
|||||||
# Fill the instance
|
# Fill the instance
|
||||||
provider.update_data(force_enable=True)
|
provider.update_data(force_enable=True)
|
||||||
|
|
||||||
with FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA.open(
|
with open(FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA, "w", encoding="utf8") as f_out:
|
||||||
"w", encoding="utf-8", newline="\n"
|
|
||||||
) as f_out:
|
|
||||||
f_out.write(provider.to_json())
|
f_out.write(provider.to_json())
|
||||||
|
|
||||||
|
|
||||||
|
@@ -33,7 +33,7 @@ def provider(sample_import_1_json, config_eos):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_import_1_json():
|
def sample_import_1_json():
|
||||||
"""Fixture that returns sample forecast data report."""
|
"""Fixture that returns sample forecast data report."""
|
||||||
with FILE_TESTDATA_WEATHERIMPORT_1_JSON.open("r", encoding="utf-8", newline=None) as f_res:
|
with open(FILE_TESTDATA_WEATHERIMPORT_1_JSON, "r") as f_res:
|
||||||
input_data = json.load(f_res)
|
input_data = json.load(f_res)
|
||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
|
86
tests/testdata/eosserver_config_1.json
vendored
@@ -1,86 +0,0 @@
|
|||||||
{
|
|
||||||
"elecprice": {
|
|
||||||
"charges_kwh": 0.21,
|
|
||||||
"provider": "ElecPriceImport"
|
|
||||||
},
|
|
||||||
"general": {
|
|
||||||
"latitude": 52.5,
|
|
||||||
"longitude": 13.4
|
|
||||||
},
|
|
||||||
"prediction": {
|
|
||||||
"historic_hours": 48,
|
|
||||||
"hours": 48
|
|
||||||
},
|
|
||||||
"load": {
|
|
||||||
"provider": "LoadImport",
|
|
||||||
"provider_settings": {
|
|
||||||
"loadakkudoktor_year_energy": 20000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"optimization": {
|
|
||||||
"hours": 48
|
|
||||||
},
|
|
||||||
"pvforecast": {
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"provider": "PVForecastImport"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"startup_eosdash": true,
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 8503,
|
|
||||||
"eosdash_host": "0.0.0.0",
|
|
||||||
"eosdash_port": 8504
|
|
||||||
},
|
|
||||||
"weather": {
|
|
||||||
"provider": "WeatherImport"
|
|
||||||
}
|
|
||||||
}
|
|