1 Commits

Author SHA1 Message Date
Dominique Lasserre
87ac127817 Workflow: Docker: Add more archs: armv6/v7, i386
* Build amd64 on any PR.
2025-02-10 23:37:48 +01:00
156 changed files with 15770 additions and 19818 deletions

2
.env
View File

@@ -3,3 +3,5 @@ EOS_SERVER__PORT=8503
EOS_SERVER__EOSDASH_PORT=8504
PYTHON_VERSION=3.12.6
BASE_IMAGE=python
IMAGE_SUFFIX=-slim

View File

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

View File

@@ -1,35 +0,0 @@
name: "Close stale pull requests/issues"
on:
schedule:
- cron: "16 00 * * *"
permissions:
contents: read
jobs:
stale:
name: Find Stale issues and PRs
runs-on: ubuntu-22.04
if: github.repository == 'Akkudoktor-EOS/EOS'
permissions:
pull-requests: write # to comment on stale pull requests
issues: write # to comment on stale issues
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
stale-pr-message: 'This pull request has been marked as stale because it has been open (more
than) 90 days with no activity. Remove the stale label or add a comment saying that you
would like to have the label removed otherwise this pull request will automatically be
closed in 30 days. Note, that you can always re-open a closed pull request at any time.'
stale-issue-message: 'This issue has been marked as stale because it has been open (more
than) 90 days with no activity. Remove the stale label or add a comment saying that you
would like to have the label removed otherwise this issue will automatically be closed in
30 days. Note, that you can always re-open a closed issue at any time.'
days-before-stale: 90
days-before-close: 30
stale-issue-label: 'stale'
stale-pr-label: 'stale'
exempt-pr-labels: 'in progress'
exempt-issue-labels: 'feature request, enhancement'
operations-per-run: 400

2
.gitignore vendored
View File

@@ -179,7 +179,7 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
#.idea/
# General
.DS_Store

View File

@@ -1,35 +0,0 @@
[general]
# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
verbosity = 3
regex-style-search=true
# Ignore rules, reference them by id or name (comma-separated)
ignore=title-trailing-punctuation, T3
# Enable specific community contributed rules
contrib=contrib-title-conventional-commits,CC1
# Set the extra-path where gitlint will search for user defined rules
extra-path=scripts/gitlint
[title-max-length]
line-length=80
[title-min-length]
min-length=5
[ignore-by-title]
# Match commit titles starting with "Release"
regex=^Release(.*)
ignore=title-max-length,body-min-length
[ignore-by-body]
# Match commits message bodies that have a line that contains 'release'
regex=(.*)release(.*)
ignore=all
[ignore-by-author-name]
# Match commits by author name (e.g. ignore dependabot commits)
regex=dependabot
ignore=all

View File

@@ -12,12 +12,12 @@ repos:
- id: check-merge-conflict
exclude: '\.rst$' # Exclude .rst files
- repo: https://github.com/PyCQA/isort
rev: 6.0.0
rev: 5.13.2
hooks:
- id: isort
name: isort
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.6
rev: v0.6.8
hooks:
# Run the linter and fix simple issues automatically
- id: ruff
@@ -25,7 +25,7 @@ repos:
# Run the formatter.
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v1.15.0'
rev: 'v1.13.0'
hooks:
- id: mypy
additional_dependencies:
@@ -33,16 +33,3 @@ repos:
- "pandas-stubs==2.2.3.241009"
- "numpy==2.1.3"
pass_filenames: false
- repo: https://github.com/jackdewinter/pymarkdown
rev: v0.9.29
hooks:
- id: pymarkdown
files: ^docs/
exclude: ^docs/_generated
args:
- --config=docs/pymarkdown.json
- scan
- repo: https://github.com/jorisroovers/gitlint
rev: v0.19.1
hooks:
- id: gitlint

View File

@@ -1,193 +0,0 @@
# Changelog
All notable changes to the akkudoktoreos project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2025-09-30
### Added
- Added Changelog for 0.0.0 amd 0.1.0
## [0.0.0] - 2025-09-30
This version represents one year of development of EOS (Energy Optimization System). From this point forward, release management will be introduced.
### Added
#### Core Features
- Energy Management System (EMS) with battery optimization
- PV (Photovoltaic) forecast integration with multiple providers
- Load prediction and forecasting capabilities
- Electricity price integration
- VRM API integration for load and PV forecasting
- Battery State of Charge (SoC) prediction and optimization
- Inverter class with AC/DC charging logic
- Electric vehicle (EV) charging optimization with configurable currents
- Home appliance scheduling optimization
- Horizon validation for shading calculations
#### API & Server
- Migration from Flask to FastAPI
- RESTful API with comprehensive endpoints
- EOSdash web interface for configuration and visualization
- Docker support with multi-architecture builds
- Web-based visualization with interactive charts
- OpenAPI/Swagger documentation
- Configurable server settings (port, host)
#### Configuration & Data Management
- JSON-based configuration system with nested support
- Configuration validation with Pydantic
- Device registry for managing multiple devices
- Persistent caching for predictions and prices
- Manual prediction updates
- Timezone support with automatic detection
- Configurable VAT rates for electricity prices
#### Optimization
- DEAP-based genetic algorithm optimization
- Multi-objective optimization (cost, battery usage, self-consumption)
- 48-hour prediction and optimization window
- AC/DC charging decision optimization
- Discharge hour optimization
- Start solution enforcement
- Fitness visualization with violin plots
- Self-consumption probability interpolator
#### Testing & Quality
- Comprehensive test suite with pytest
- Unit tests for major components (EMS, battery, inverter, load, optimization)
- Integration tests for server endpoints
- Pre-commit hooks for code quality
- Type checking with mypy
- Code formatting with ruff and isort
- Markdown linting
#### Documentation
- Conceptual documentation
- API documentation with Sphinx
- ReadTheDocs integration
- Docker setup instructions
- Contributing guidelines
- English README translation
#### Providers & Integrations
- PVForecast.Akkudoktor provider
- BrightSky weather provider
- ClearOutside weather provider
- Electricity price provider
### Changed
- Python version requirement updated to 3.10+
- Optimized Inverter class for improved SCR calculation performance
- Improved caching mechanisms for better performance
- Enhanced visualization with proper timestamp handling
- Updated dependency management with automatic Dependabot updates
- Restructured code into logical submodules
- Package directory structure reorganization
- Improved error handling and logging
- Windows compatibility improvements
### Fixed
- Cross-site scripting (XSS) vulnerabilities
- ReDoS vulnerability in duration parsing
- Timezone and daylight saving time handling
- BrightSky provider with None humidity data
- Negative values in load mean adjusted calculations
- SoC calculation bugs
- AC charge efficiency in price calculations
- Optimization timing bugs
- Docker BuildKit compatibility
- Float value handling in user horizon configuration
- Circular runtime import issues
- Load simulation data return issues
- Multiple optimization-related bugs
### Security
- Added Bandit security checks
- Fixed XSS vulnerabilities
- Mitigated ReDoS attacks with input length validation
- Improved credential management with environment variables
### Dependencies
Major dependencies included in this release:
- FastAPI 0.115.14
- Pydantic 2.11.9
- NumPy 2.3.3
- Pandas 2.3.2
- Scikit-learn 1.7.2
- Uvicorn 0.36.0
- Bokeh 3.8.0
- Matplotlib 3.10.6
- PVLib 0.13.1
- Python-FastHTML 0.12.29
### Development Notes
This version encompasses all development from the initial commit (February 16, 2024) through September 29, 2025. The project evolved from a basic energy optimization concept to a comprehensive energy management system with:
- 698+ commits
- Multiple contributor involvement
- Continuous integration/deployment setup
- Automated dependency updates
- Comprehensive testing infrastructure
### Migration Notes
As this is the initial versioned release, no migration is required. Future releases will include migration guides as needed.
---
**Full Changelog**: Initial development phase (v0.0.0)
## v0.1.0-a0 (2025-09-30)
### BREAKING CHANGE
- This is a BREAKING CHANGE as the configuration structure changed
once again and the server API was also enhanced and streamlined. The server API
that is used by Andreas and Jörg in their videos has not changed
- This is a BREAKING CHANGE as the configuration structure changed
once again and the server API was also enhanced and streamlined. The server API
that is used by Andreas and Jörg in their videos has not changed.
- EOS configuration changed. V1 API changed.
- Default IP address for EOS and EOSdash changed to 127.0.0.1
- Azimuth configurations that followed the PVForecastAkkudoktor convention
(north=+-180, east=-90, south=0, west=90) must be converted to the general azimuth definition:
north=0, east=90, south=180, west=270.
### Feat
- **VRM forecast**: add load and pv forecast by VRM API (#611)
- run pytest for PRs
- be helpful, provide a list of valid routes when visiting /
- add documentation, enable makefile driven usage
- Detailliertere README
- andere ports/bind ips erlauben
### Fix
- dependencies and optimization solution beginning
- typos in bokeh.py
- automatic optimization
- handle float values in userhorizon configuration (#657)
- **docker**: make EOSDash accessible in Docker containers (#656)
- **ElecPriceEnergyCharts**: get history series, update docs (#606)
- logging, prediction update, multiple bugs (#584)
- add required fields to example optimization request (#574)
- pvforecast fails when there is only a single plane (#569)
- delete empty inverter from testdata optimize_input_2.json (#568)
- azimuth setting of pvforecastakkudoktor provider (#567)
- BrightSky with None humidity data (#555)
- Catch optimize error and return error message. (#534)
- Circular runtime import Closes #533 (#535)
- **docker**: enable BuildKit to support --mount (closes #493)
- mitigate ReDoS in to_duration via max input length check (closes #494) (#523)
- relax stale issue/pr handling
- remove verbose comment
- make port configurable via env
### Refactor
- remove `README-DE.md`

View File

@@ -6,7 +6,7 @@ The `EOS` project is in early development, therefore we encourage contribution i
## Documentation
Latest development documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/).
Latest development documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/main/).
## Bug Reports
@@ -21,8 +21,8 @@ There are just too many possibilities and the project would drown in tickets oth
## Code Contributions
We welcome code contributions and bug fixes via [Pull Requests](https://github.com/Akkudoktor-EOS/EOS/pulls).
To make collaboration easier, we require pull requests to pass code style, unit tests, and commit
message style checks.
To make collaboration easier, we require pull requests to pass code style and unit tests.
### Setup development environment
@@ -33,7 +33,6 @@ See also [README.md](README.md).
python -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
pip install -e .
```
Install make to get access to helpful shortcuts (documentation generation, manual formatting, etc.).
@@ -60,7 +59,6 @@ To run formatting automatically before every commit:
```bash
pre-commit install
pre-commit install --hook-type commit-msg
```
Or run them manually:
@@ -76,8 +74,3 @@ Use `pytest` to run tests locally:
```bash
python -m pytest -vs --cov src --cov-report term-missing tests/
```
### Commit message style
Our commit message checks use [`gitlint`](https://github.com/jorisroovers/gitlint). The checks
enforce the [`Conventional Commits`](https://www.conventionalcommits.org) commit message style.

View File

@@ -1,6 +1,7 @@
# syntax=docker/dockerfile:1.7
ARG PYTHON_VERSION=3.12.7
FROM python:${PYTHON_VERSION}-slim
ARG PYTHON_VERSION=3.12.8
ARG BASE_IMAGE=python
ARG IMAGE_SUFFIX=-slim
FROM ${BASE_IMAGE}:${PYTHON_VERSION}${IMAGE_SUFFIX} AS base
LABEL source="https://github.com/Akkudoktor-EOS/EOS"
@@ -10,12 +11,10 @@ ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
ENV EOS_OUTPUT_DIR="${EOS_DIR}/output"
ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
# Overwrite when starting the container in a production environment
ENV EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
WORKDIR ${EOS_DIR}
RUN adduser --system --group --no-create-home eos \
# Use useradd over adduser to support both debian:x-slim and python:x-slim base images
RUN useradd --system --no-create-home --shell /usr/sbin/nologin eos \
&& mkdir -p "${MPLCONFIGDIR}" \
&& chown eos "${MPLCONFIGDIR}" \
&& mkdir -p "${EOS_CACHE_DIR}" \
@@ -25,35 +24,95 @@ RUN adduser --system --group --no-create-home eos \
&& mkdir -p "${EOS_CONFIG_DIR}" \
&& chown eos "${EOS_CONFIG_DIR}"
ARG APT_PACKAGES
ENV APT_PACKAGES="${APT_PACKAGES}"
RUN --mount=type=cache,sharing=locked,target=/var/lib/apt/lists \
--mount=type=cache,sharing=locked,target=/var/cache/apt \
rm /etc/apt/apt.conf.d/docker-clean; \
if [ -n "${APT_PACKAGES}" ]; then \
apt-get update \
&& apt-get install -y --no-install-recommends ${APT_PACKAGES}; \
fi
FROM base AS build
ARG APT_BUILD_PACKAGES
ENV APT_BUILD_PACKAGES="${APT_BUILD_PACKAGES}"
RUN --mount=type=cache,sharing=locked,target=/var/lib/apt/lists \
--mount=type=cache,sharing=locked,target=/var/cache/apt \
rm /etc/apt/apt.conf.d/docker-clean; \
if [ -n "${APT_BUILD_PACKAGES}" ]; then \
apt-get update \
&& apt-get install -y --no-install-recommends ${APT_BUILD_PACKAGES}; \
fi
ARG RUSTUP_INSTALL
ENV RUSTUP_INSTALL="${RUSTUP_INSTALL}"
ENV RUSTUP_HOME=/opt/rust
ENV CARGO_HOME=/opt/rust
ENV PATH="$RUSTUP_HOME/bin:$PATH"
ARG PIP_EXTRA_INDEX_URL
ENV PIP_EXTRA_INDEX_URL="${PIP_EXTRA_INDEX_URL}"
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=tmpfs,target=/root/.cargo \
dpkgArch=$(dpkg --print-architecture) \
&& if [ -n "${RUSTUP_INSTALL}" ]; then \
case "$dpkgArch" in \
# armv6
armel) \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --target arm-unknown-linux-gnueabi --no-modify-path \
;; \
*) \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path \
;; \
esac \
&& rustc --version \
&& cargo --version; \
fi \
# Install 32bit fix for pendulum, can be removed after next pendulum release (> 3.0.0)
&& case "$dpkgArch" in \
# armv7/armv6
armhf|armel) \
git clone https://github.com/python-pendulum/pendulum.git \
&& git -C pendulum checkout -b 3.0.0 3.0.0 \
# Apply 32bit patch
&& git -C pendulum -c user.name=ci -c user.email=ci@github.com cherry-pick b84b97625cdea00f8ab150b8b35aa5ccaaf36948 \
&& cd pendulum \
# Use pip3 over pip to support both debian:x and python:x base images
&& pip3 install maturin \
&& maturin build --release --out dist \
&& pip3 install dist/*.whl --break-system-packages \
&& cd - \
;; \
esac
COPY requirements.txt .
# Use tmpfs for cargo due to qemu (multiarch) limitations
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
--mount=type=tmpfs,target=/root/.cargo \
# Use pip3 over pip to support both debian:x and python:x base images
pip3 install -r requirements.txt --break-system-packages
FROM base AS final
# Copy all python dependencies previously installed or built to the final stage.
COPY --from=build /usr/local/ /usr/local/
COPY --from=build /opt/eos/requirements.txt .
COPY pyproject.toml .
RUN mkdir -p src && pip install -e .
RUN --mount=type=cache,target=/root/.cache/pip \
# Use pip3 over pip to support both debian:x and python:x base images
mkdir -p src && pip3 install -e . --break-system-packages
COPY src src
# Create minimal default configuration for Docker to fix EOSDash accessibility (#629)
# This ensures EOSDash binds to 0.0.0.0 instead of 127.0.0.1 in containers
RUN echo '{\n\
"server": {\n\
"host": "0.0.0.0",\n\
"port": 8503,\n\
"startup_eosdash": true,\n\
"eosdash_host": "0.0.0.0",\n\
"eosdash_port": 8504\n\
}\n\
}' > "${EOS_CONFIG_DIR}/EOS.config.json" \
&& chown eos:eos "${EOS_CONFIG_DIR}/EOS.config.json"
USER eos
ENTRYPOINT []
EXPOSE 8503
EXPOSE 8504
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}"]

View File

@@ -1,5 +1,5 @@
# Define the targets
.PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format gitlint mypy run run-dev
.PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format mypy run run-dev
# Default target
all: help
@@ -11,22 +11,16 @@ help:
@echo " pip - Install dependencies from requirements.txt."
@echo " pip-dev - Install dependencies from requirements-dev.txt."
@echo " format - Format source code."
@echo " gitlint - Lint last commit message."
@echo " mypy - Run mypy."
@echo " install - Install EOS in editable form (development mode) into virtual environment."
@echo " docker-run - Run entire setup on docker"
@echo " docker-build - Rebuild docker image"
@echo " docs - Generate HTML documentation (in build/docs/html/)."
@echo " read-docs - Read HTML documentation in your browser."
@echo " gen-docs - Generate openapi.json and docs/_generated/*."
@echo " clean-docs - Remove generated documentation."
@echo " run - Run EOS production server in virtual environment."
@echo " run-dev - Run EOS development server in virtual environment (automatically reloads)."
@echo " run-dash - Run EOSdash production server in virtual environment."
@echo " run-dash-dev - Run EOSdash development server in virtual environment (automatically reloads)."
@echo " test - Run tests."
@echo " test-full - Run tests with full optimization."
@echo " test-ci - Run tests as CI does. No user config file allowed."
@echo " gen-docs - Generate openapi.json and docs/_generated/*.""
@echo " clean-docs - Remove generated documentation.""
@echo " run - Run EOS production server in the virtual environment."
@echo " run-dev - Run EOS development server in the virtual environment (automatically reloads)."
@echo " dist - Create distribution (in dist/)."
@echo " clean - Remove generated documentation, distribution and virtual environment."
@@ -76,11 +70,6 @@ read-docs: docs
@echo "Read the documentation in your browser"
.venv/bin/python -m webbrowser build/docs/html/index.html
# Clean Python bytecode
clean-bytecode:
find . -type d -name "__pycache__" -exec rm -r {} +
find . -type f -name "*.pyc" -delete
# Clean target to remove generated documentation and documentation artefacts
clean-docs:
@echo "Searching and deleting all '_autosum' directories in docs..."
@@ -96,19 +85,11 @@ clean: clean-docs
run:
@echo "Starting EOS production server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eos
.venv/bin/python src/akkudoktoreos/server/eos.py
run-dev:
@echo "Starting EOS development server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eos --host localhost --port 8503 --reload true
run-dash:
@echo "Starting EOSdash production server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eosdash
run-dash-dev:
@echo "Starting EOSdash development server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --reload true
.venv/bin/python src/akkudoktoreos/server/eos.py --host localhost --port 8503 --reload true
# Target to setup tests.
test-setup: pip-dev
@@ -119,11 +100,6 @@ test:
@echo "Running tests..."
.venv/bin/pytest -vs --cov src --cov-report term-missing
# Target to run tests as done by CI on Github.
test-ci:
@echo "Running tests as CI..."
.venv/bin/pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing
# Target to run all tests.
test-full:
@echo "Running all tests..."
@@ -133,10 +109,6 @@ test-full:
format:
.venv/bin/pre-commit run --all-files
# Target to trigger gitlint using pre-commit for the last commit message
gitlint:
.venv/bin/pre-commit run gitlint --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
# Target to format code.
mypy:
.venv/bin/mypy

View File

@@ -8,19 +8,9 @@ Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedoc
See [CONTRIBUTING.md](CONTRIBUTING.md).
## System requirements
- Python >= 3.11, < 3.13
- Architecture: amd64, aarch64 (armv8)
- OS: Linux, Windows, macOS
Note: For Python 3.13 some dependencies (e.g. [Pendulum](https://github.com/python-pendulum/Pendulum)) are not yet available on https://pypi.org and have to be manually compiled (a recent [Rust](https://www.rust-lang.org/tools/install) installation is required).
Other architectures (e.g. armv6, armv7) are unsupported for now, because a multitude of dependencies are not available on https://piwheels.org and have to be built manually (a recent Rust installation and [GCC](https://gcc.gnu.org/) are required, Python 3.11 is recommended).
## Installation
The project requires Python 3.11 or newer. Docker images (amd64/aarch64) can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos).
The project requires Python 3.10 or newer. Official docker images can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos).
Following sections describe how to locally start the EOS server on `http://localhost:8503`.
@@ -33,19 +23,16 @@ Linux:
```bash
python -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e .
```
Windows:
```cmd
python -m venv .venv
.venv\Scripts\Activate
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pip install -e .
.venv\Scripts\pip install -r requirements.txt
```
Finally, start the EOS server to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`):
Finally, start the EOS server:
Linux:
@@ -61,8 +48,6 @@ Windows:
### Docker
Start EOS with following command to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`):
```bash
docker compose up
```

View File

@@ -11,6 +11,12 @@ services:
dockerfile: "Dockerfile"
args:
PYTHON_VERSION: "${PYTHON_VERSION}"
BASE_IMAGE: "${BASE_IMAGE}"
IMAGE_SUFFIX: "${IMAGE_SUFFIX}"
APT_PACKAGES: "${APT_PACKAGES:-}"
APT_BUILD_PACKAGES: "${APT_BUILD_PACKAGES:-}"
PIP_EXTRA_INDEX_URL: "${PIP_EXTRA_INDEX_URL:-}"
RUSTUP_INSTALL: "${RUSTUP_INSTALL:-}"
env_file:
- .env
environment:
@@ -21,25 +27,5 @@ services:
- EOS_ELECPRICE__PROVIDER=ElecPriceAkkudoktor
- EOS_ELECPRICE__CHARGES_KWH=0.21
ports:
# Configure what ports to expose on host
- "${EOS_SERVER__PORT}:8503"
- "${EOS_SERVER__EOSDASH_PORT}:8504"
# Volume mount configuration (optional)
# IMPORTANT: When mounting local directories, the default config won't be available.
# You must create an EOS.config.json file in your local config directory with:
# {
# "server": {
# "host": "0.0.0.0", # Required for Docker container accessibility
# "port": 8503,
# "startup_eosdash": true,
# "eosdash_host": "0.0.0.0", # Required for Docker container accessibility
# "eosdash_port": 8504
# }
# }
#
# Example volume mounts (uncomment to use):
# volumes:
# - ./config:/opt/eos/config # Mount local config directory
# - ./cache:/opt/eos/cache # Mount local cache directory
# - ./output:/opt/eos/output # Mount local output directory
- "${EOS_SERVER__PORT}:${EOS_SERVER__PORT}"
- "${EOS_SERVER__EOSDASH_PORT}:${EOS_SERVER__EOSDASH_PORT}"

View File

@@ -15,6 +15,10 @@ Properties:
timezone (Optional[str]): Computed time zone string based on the specified latitude
and longitude.
Validators:
validate_latitude (float): Ensures `latitude` is within the range -90 to 90.
validate_longitude (float): Ensures `longitude` is within the range -180 to 180.
:::{table} general
:widths: 10 20 10 5 5 30
:align: left
@@ -23,10 +27,12 @@ Properties:
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| 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_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) (°) |
| 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. |
| 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_file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration file. |
:::
@@ -40,6 +46,7 @@ Properties:
"general": {
"data_folder_path": null,
"data_output_subpath": "output",
"data_cache_subpath": "cache",
"latitude": 52.52,
"longitude": 13.405
}
@@ -55,66 +62,18 @@ Properties:
"general": {
"data_folder_path": null,
"data_output_subpath": "output",
"data_cache_subpath": "cache",
"latitude": 52.52,
"longitude": 13.405,
"timezone": "Europe/Berlin",
"data_output_path": null,
"data_cache_path": null,
"config_folder_path": "/home/user/.config/net.akkudoktoreos.net",
"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
:::{table} logging
@@ -123,10 +82,8 @@ Properties:
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| level | `EOS_LOGGING__LEVEL` | `Optional[str]` | `rw` | `None` | This is deprecated. Use console_level and file_level instead. |
| console_level | `EOS_LOGGING__CONSOLE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level when logging to console. |
| file_level | `EOS_LOGGING__FILE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level when logging to file. |
| file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Computed log file path based on data output path. |
| level | `EOS_LOGGING__LEVEL` | `Optional[str]` | `rw` | `None` | EOS default logging level. |
| root_level | | `str` | `ro` | `N/A` | Root logger logging level. |
:::
### Example Input
@@ -136,9 +93,7 @@ Properties:
{
"logging": {
"level": null,
"console_level": "TRACE",
"file_level": "TRACE"
"level": "INFO"
}
}
```
@@ -150,10 +105,8 @@ Properties:
{
"logging": {
"level": null,
"console_level": "TRACE",
"file_level": "TRACE",
"file_path": "/home/user/.local/share/net.akkudoktoreos.net/output/eos.log"
"level": "INFO",
"root_level": "INFO"
}
}
```
@@ -424,7 +377,6 @@ Validators:
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `Optional[float]` | `rw` | `None` | Electricity price charges (€/kWh). |
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `Optional[float]` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
| provider_settings | `EOS_ELECPRICE__PROVIDER_SETTINGS` | `Optional[akkudoktoreos.prediction.elecpriceimport.ElecPriceImportCommonSettings]` | `rw` | `None` | Provider settings |
:::
@@ -437,7 +389,6 @@ Validators:
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"provider_settings": null
}
}
@@ -479,7 +430,7 @@ Validators:
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| provider | `EOS_LOAD__PROVIDER` | `Optional[str]` | `rw` | `None` | Load provider id of provider to be used. |
| provider_settings | `EOS_LOAD__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings, akkudoktoreos.prediction.loadvrm.LoadVrmCommonSettings, akkudoktoreos.prediction.loadimport.LoadImportCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
| provider_settings | `EOS_LOAD__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings, akkudoktoreos.prediction.loadimport.LoadImportCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
:::
### Example Input/Output
@@ -522,33 +473,6 @@ Validators:
}
```
### Common settings for VRM API
:::{table} load::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| load_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
| load_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
:::
#### Example Input/Output
```{eval-rst}
.. code-block:: json
{
"load": {
"provider_settings": {
"load_vrm_token": "your-token",
"load_vrm_idsite": 12345
}
}
}
```
### Common settings for load data import from file
:::{table} load::provider_settings
@@ -583,9 +507,8 @@ Validators:
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| provider | `EOS_PVFORECAST__PROVIDER` | `Optional[str]` | `rw` | `None` | PVForecast provider id of provider to be used. |
| provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings, akkudoktoreos.prediction.pvforecastvrm.PVforecastVrmCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
| planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. |
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set |
| provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | Provider settings |
| planes_peakpower | | `List[float]` | `ro` | `N/A` | Compute a list of the peak power per active planes. |
| planes_azimuth | | `List[float]` | `ro` | `N/A` | Compute a list of the azimuths per active planes. |
| planes_tilt | | `List[float]` | `ro` | `N/A` | Compute a list of the tilts per active planes. |
@@ -601,11 +524,10 @@ Validators:
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": null,
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 180.0,
"surface_azimuth": 10.0,
"userhorizon": [
10.0,
20.0,
@@ -627,7 +549,7 @@ Validators:
},
{
"surface_tilt": 20.0,
"surface_azimuth": 90.0,
"surface_azimuth": 20.0,
"userhorizon": [
5.0,
15.0,
@@ -648,7 +570,7 @@ Validators:
"strings_per_inverter": 2
}
],
"max_planes": 0
"provider_settings": null
}
}
```
@@ -661,11 +583,10 @@ Validators:
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": null,
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 180.0,
"surface_azimuth": 10.0,
"userhorizon": [
10.0,
20.0,
@@ -687,7 +608,7 @@ Validators:
},
{
"surface_tilt": 20.0,
"surface_azimuth": 90.0,
"surface_azimuth": 20.0,
"userhorizon": [
5.0,
15.0,
@@ -708,14 +629,14 @@ Validators:
"strings_per_inverter": 2
}
],
"max_planes": 0,
"provider_settings": null,
"planes_peakpower": [
5.0,
3.5
],
"planes_azimuth": [
180.0,
90.0
10.0,
20.0
],
"planes_tilt": [
10.0,
@@ -741,116 +662,6 @@ Validators:
}
```
### PV Forecast Plane Configuration
:::{table} pvforecast::planes::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| surface_tilt | `Optional[float]` | `rw` | `30.0` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
| surface_azimuth | `Optional[float]` | `rw` | `180.0` | Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). |
| userhorizon | `Optional[List[float]]` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
| peakpower | `Optional[float]` | `rw` | `None` | Nominal power of PV system in kW. |
| pvtechchoice | `Optional[str]` | `rw` | `crystSi` | PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. |
| mountingplace | `Optional[str]` | `rw` | `free` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
| loss | `Optional[float]` | `rw` | `14.0` | Sum of PV system losses in percent |
| trackingtype | `Optional[int]` | `rw` | `None` | Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. |
| optimal_surface_tilt | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
| optimalangles | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
| albedo | `Optional[float]` | `rw` | `None` | Proportion of the light hitting the ground that it reflects back. |
| module_model | `Optional[str]` | `rw` | `None` | Model of the PV modules of this plane. |
| inverter_model | `Optional[str]` | `rw` | `None` | Model of the inverter of this plane. |
| inverter_paco | `Optional[int]` | `rw` | `None` | AC power rating of the inverter [W]. |
| modules_per_string | `Optional[int]` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
| strings_per_inverter | `Optional[int]` | `rw` | `None` | Number of the strings of the inverter of this plane. |
:::
#### Example Input/Output
```{eval-rst}
.. code-block:: json
{
"pvforecast": {
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 180.0,
"userhorizon": [
10.0,
20.0,
30.0
],
"peakpower": 5.0,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 0,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 6000,
"modules_per_string": 20,
"strings_per_inverter": 2
},
{
"surface_tilt": 20.0,
"surface_azimuth": 90.0,
"userhorizon": [
5.0,
15.0,
25.0
],
"peakpower": 3.5,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 1,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 4000,
"modules_per_string": 20,
"strings_per_inverter": 2
}
]
}
}
```
### Common settings for VRM API
:::{table} pvforecast::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| pvforecast_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
| pvforecast_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
:::
#### Example Input/Output
```{eval-rst}
.. code-block:: json
{
"pvforecast": {
"provider_settings": {
"pvforecast_vrm_token": "your-token",
"pvforecast_vrm_idsite": 12345
}
}
}
```
### Common settings for pvforecast data import from file or JSON string
:::{table} pvforecast::provider_settings
@@ -878,6 +689,89 @@ Validators:
}
```
### PV Forecast Plane Configuration
:::{table} pvforecast::planes::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| surface_tilt | `Optional[float]` | `rw` | `None` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
| surface_azimuth | `Optional[float]` | `rw` | `None` | Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). |
| userhorizon | `Optional[List[float]]` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
| peakpower | `Optional[float]` | `rw` | `None` | Nominal power of PV system in kW. |
| pvtechchoice | `Optional[str]` | `rw` | `crystSi` | PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. |
| mountingplace | `Optional[str]` | `rw` | `free` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
| loss | `Optional[float]` | `rw` | `14.0` | Sum of PV system losses in percent |
| trackingtype | `Optional[int]` | `rw` | `None` | Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. |
| optimal_surface_tilt | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
| optimalangles | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
| albedo | `Optional[float]` | `rw` | `None` | Proportion of the light hitting the ground that it reflects back. |
| module_model | `Optional[str]` | `rw` | `None` | Model of the PV modules of this plane. |
| inverter_model | `Optional[str]` | `rw` | `None` | Model of the inverter of this plane. |
| inverter_paco | `Optional[int]` | `rw` | `None` | AC power rating of the inverter. [W] |
| modules_per_string | `Optional[int]` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
| strings_per_inverter | `Optional[int]` | `rw` | `None` | Number of the strings of the inverter of this plane. |
:::
#### Example Input/Output
```{eval-rst}
.. code-block:: json
{
"pvforecast": {
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 10.0,
"userhorizon": [
10.0,
20.0,
30.0
],
"peakpower": 5.0,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 0,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 6000,
"modules_per_string": 20,
"strings_per_inverter": 2
},
{
"surface_tilt": 20.0,
"surface_azimuth": 20.0,
"userhorizon": [
5.0,
15.0,
25.0
],
"peakpower": 3.5,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 1,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 4000,
"modules_per_string": 20,
"strings_per_inverter": 2
}
]
}
}
```
## Weather Forecast Configuration
:::{table} weather
@@ -932,17 +826,20 @@ Validators:
## Server Configuration
Attributes:
To be added
:::{table} server
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| host | `EOS_SERVER__HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `127.0.0.1` | EOS server IP address. |
| host | `EOS_SERVER__HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `0.0.0.0` | EOS server IP address. |
| port | `EOS_SERVER__PORT` | `Optional[int]` | `rw` | `8503` | EOS server IP port number. |
| verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output |
| startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `Optional[bool]` | `rw` | `True` | EOS server to start EOSdash server. |
| eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `127.0.0.1` | EOSdash server IP address. |
| eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `0.0.0.0` | EOSdash server IP address. |
| eosdash_port | `EOS_SERVER__EOSDASH_PORT` | `Optional[int]` | `rw` | `8504` | EOSdash server IP port number. |
:::
@@ -953,11 +850,11 @@ Validators:
{
"server": {
"host": "127.0.0.1",
"host": "0.0.0.0",
"port": 8503,
"verbose": false,
"startup_eosdash": true,
"eosdash_host": "127.0.0.1",
"eosdash_host": "0.0.0.0",
"eosdash_port": 8504
}
}
@@ -992,21 +889,12 @@ Validators:
"general": {
"data_folder_path": null,
"data_output_subpath": "output",
"data_cache_subpath": "cache",
"latitude": 52.52,
"longitude": 13.405
},
"cache": {
"subpath": "cache",
"cleanup_interval": 300.0
},
"ems": {
"startup_delay": 5.0,
"interval": 300.0
},
"logging": {
"level": null,
"console_level": "TRACE",
"file_level": "TRACE"
"level": "INFO"
},
"devices": {
"batteries": [
@@ -1052,7 +940,6 @@ Validators:
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"provider_settings": null
},
"load": {
@@ -1061,11 +948,10 @@ Validators:
},
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": null,
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 180.0,
"surface_azimuth": 10.0,
"userhorizon": [
10.0,
20.0,
@@ -1087,7 +973,7 @@ Validators:
},
{
"surface_tilt": 20.0,
"surface_azimuth": 90.0,
"surface_azimuth": 20.0,
"userhorizon": [
5.0,
15.0,
@@ -1108,18 +994,18 @@ Validators:
"strings_per_inverter": 2
}
],
"max_planes": 0
"provider_settings": null
},
"weather": {
"provider": "WeatherImport",
"provider_settings": null
},
"server": {
"host": "127.0.0.1",
"host": "0.0.0.0",
"port": 8503,
"verbose": false,
"startup_eosdash": true,
"eosdash_host": "127.0.0.1",
"eosdash_host": "0.0.0.0",
"eosdash_port": 8504
},
"utils": {}

View File

@@ -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
**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.
@@ -378,141 +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`: {
"anyOf": [
{},
{
"type": "null"
}
],
"description": "The value to assign to the specified configuration path (can be None).",
"title": "Value"
}
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## GET /v1/health
**Links**: [local](http://localhost:8503/docs#/default/fastapi_health_get_v1_health_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_health_get_v1_health_get)
Fastapi Health Get
```
Health check endpoint to verify that the EOS server is alive.
```
**Responses**:
- **200**: Successful Response
---
## GET /v1/logging/log
**Links**: [local](http://localhost:8503/docs#/default/fastapi_logging_get_log_v1_logging_log_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_logging_get_log_v1_logging_log_get)
Fastapi Logging Get Log
```
Get structured log entries from the EOS log file.
Filters and returns log entries based on the specified query parameters. The log
file is expected to contain newline-delimited JSON entries.
Args:
limit (int): Maximum number of entries to return.
level (Optional[str]): Filter logs by severity level (e.g., DEBUG, INFO).
contains (Optional[str]): Return only logs that include this string in the message.
regex (Optional[str]): Return logs that match this regular expression in the message.
from_time (Optional[str]): ISO 8601 timestamp to filter logs not older than this.
to_time (Optional[str]): ISO 8601 timestamp to filter logs not newer than this.
tail (bool): If True, fetch the most recent log entries (like `tail`).
Returns:
JSONResponse: A JSON list of log entries.
```
**Parameters**:
- `limit` (query, optional): Maximum number of log entries to return.
- `level` (query, optional): Filter by log level (e.g., INFO, ERROR).
- `contains` (query, optional): Filter logs containing this substring.
- `regex` (query, optional): Filter logs by matching regex in message.
- `from_time` (query, optional): Start time (ISO format) for filtering logs.
- `to_time` (query, optional): End time (ISO format) for filtering logs.
- `tail` (query, optional): If True, returns the most recent lines (tail mode).
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## PUT /v1/measurement/data
**Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put)
@@ -729,93 +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",
"additionalProperties": true
},
{
"type": "null"
}
],
"title": "Data"
}
**Responses**:
- **200**: Successful Response
- **422**: Validation Error
---
## GET /v1/prediction/keys
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_keys_get_v1_prediction_keys_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_keys_get_v1_prediction_keys_get)
@@ -859,7 +516,7 @@ Args:
- `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**:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 KiB

View File

@@ -0,0 +1,9 @@
% SPDX-License-Identifier: Apache-2.0
# About Akkudoktor EOS
The Energy System Simulation and Optimization System (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.

View File

@@ -20,22 +20,17 @@ EOS Architecture
### Configuration
The configuration controls all aspects of EOS: optimization, prediction, measurement, and energy
management.
The configuration controls all aspects of EOS: optimization, prediction, measurement, and energy management.
### Energy Management
Energy management is the overall process to provide planning data for scheduling the different
devices in your system in an optimal way. Energy management cares for the update of predictions and
the optimization of the planning based on the simulated behavior of the devices. The planning is on
the hour.
Energy management is the overall process to provide planning data for scheduling the different devices in your system in an optimal way. Energy management cares for the update of predictions and the optimization of the planning based on the simulated behavior of the devices. The planning is on the hour. Sub-hour energy management is left
### Optimization
### Device Simulations
Device simulations simulate devices' behavior based on internal logic and predicted data. They
provide the data needed for optimization.
Device simulations simulate devices' behavior based on internal logic and predicted data. They provide the data needed for optimization.
### Predictions
@@ -43,8 +38,7 @@ Predictions provide predicted future data to be used by the optimization.
### Measurements
Measurements are utilized to refine predictions using real data from your system, thereby enhancing
accuracy.
Measurements are utilized to refine predictions using real data from your system, thereby enhancing accuracy.
### EOS Server

View File

@@ -31,10 +31,10 @@ Use endpoint `POST /v1/config/reset` to reset the configuration to the values in
The configuration sources and their priorities are as follows:
1. `Settings`: Provided during runtime by the REST interface
2. `Environment Variables`: Defined at startup of the REST server and during runtime
3. `EOS Configuration File`: Read at startup of the REST server and on request
4. `Default Values`
1. **Runtime Config Updates**: Provided during runtime by the REST interface
2. **Environment Variables**: Defined at startup of the REST server and during runtime
3. **EOS Configuration File**: Read at startup of the REST server and on request
4. **Default Values**
### Runtime Config Updates

View File

@@ -1,5 +1,4 @@
% SPDX-License-Identifier: Apache-2.0
(integration-page)=
# Integration
@@ -18,24 +17,18 @@ APIs, and online services in creative and practical ways.
Andreas Schmitz uses [Node-RED](https://nodered.org/) as part of his home automation setup.
### Node-Red Resources
### Resources
- [Installation Guide (German)](https://www.youtube.com/playlist?list=PL8_vk9A-s7zLD865Oou6y3EeQLlNtu-Hn)
\— A detailed guide on integrating EOS with `Node-RED`.
- [Installation Guide (German)](https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/) — A detailed guide on integrating an early version of EOS with
`Node-RED`.
## Home Assistant
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform that
emphasizes local control and user privacy.
(duetting-solution)=
### Home Assistant Resources
### Resources
- Duetting's [EOS Home Assistant Addon](https://github.com/Duetting/ha_eos_addon) — Additional
details can be found in this [discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).
## EOS Connect
[EOS connect](https://github.com/ohAnd/EOS_connect) uses `EOS` for energy management and optimization,
and connects to smart home platforms to monitor, forecast, and control energy flows.
details can be found in this
[discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).

View File

@@ -1,180 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
# Introduction
The Energy System Simulation and Optimization System (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.
After successfully installing a PV system with or without battery storage, most owners
first priority is often to charge the electric car with surplus energy in order to use
the electricity generated by the PV system cost-effectively for electromobility.
After initial experiences, the desire to include battery storage and dynamic electricity
prices in the solution soon arises. The market already offers various commercial and
non-commercial solutions for this, such as the popular open source hardware and software
solutions evcc or openWB.
Some solutions take into account the current values of the system such as PV power
output, battery storage charge level or the current electricity price to decide whether
to charge the electric car with PV surplus or from the grid (e.g. openWB), some use
historical consumption values and PV forecast data for their calculations, but leave out
the current electricity prices and charging the battery storage from the power grid
(Predbat). Others are specialiced on working in combination with a specific smart home
solution (e.g. emhass). Still others focus on certain consumers, such as the electric car,
or are currently working on integrating the forecast values (evcc). And some are commercial
devices that require an electrician to install them and expect a certain ecosystem
(e.g. Sunny Home Manager).
The Akkudoktor EOS
- takes into account historical, current and forecast data such as consumption values, PV
forecast data, electricity price forecast, battery storage and electric car charge levels
- the simulation also takes into account the possibility of charging the battery storage
from the grid at low electricity prices
- is not limited to certain consumers, but includes electric cars, heat pumps or more
powerful consumers such as tumble dryers
- is independent of a specific smart home solution and can also be integrated into
self-developed solutions if desired
- is a free and independent open source software solution
![Introdution](../_static/introduction/introduction.png)
The challenge is to charge (electric car) or start the consumers (washing machine, dryer)
at the right time and to do so as cost-efficiently as possible. If PV yield forecast,
battery storage and dynamic electricity price forecasts are included in the calculation,
the possibilities increase, but unfortunately so does the complexity.
The Akkudoktor EOS addresses this challenge by simulating energy flows in the household
based on target values, forecast data and current operating data over a 48-hour
observation period, running through a large number of different scenarios and finally
providing a cost-optimized plan for the current day controlling the relevant consumers.
## Prerequisites
- Technical requirements
- Input data
### Technical requirements
- reasonably fast computer on which EOS is installed
- controllable energy system consisting of photovoltaic system, solar battery storage,
energy intensive consumers that must provide the appropriate interfaces
- integration solution for integrating the energy system and EOS
### Input Data
![Overview](../_static/introduction/overview.png)
The EOS requires various types of data for the simulation:
Forecast data
- PV yield forecast
- Expected household consumption
- Electricity price forecast
- Forecast temperature trend (if heatpump is used)
Basic data and current operating data
- Current charge level of the battery storage
- Value of electricity in the battery storage
- Current charge level of the electric car
- Energy consumption and running time of dishwasher, washing machine and tumble dryer
Target values
- Charge level the electric car should reach in the next few hours
- Consumers to run in the next few hours
There are various service providers available for PV forecasting that calculate forecast
data for a PV system based on the various influencing factors, such as system size,
orientation, location, time of year and weather conditions. EOS also offers a
[PV forecasting service](#prediction-page) which can be used. This service uses
public data in the background.
For the forecast of household consumption EOS provides a standard load curve for an
average day based on annual household consumption that you can fetch via API. This data
was compiled based on data from several households and provides an initial usable basis.
Alternatively your own collected historical data could be used to reflect your personal
consumption behaviour.
## Simulation Results
Based on the input data, the EOS uses a genetic algorithm to create a cost-optimized
schedule for the coming hours from numerous simulations of the overall system.
The plan created contains for each of the coming hours
- Control information
- whether and with what power the battery storage should be charged from the grid
- when the battery storage should be charged via the PV system
- whether discharging the battery storage is permitted or not
- when and with what power the electric car should be charged
- when a household appliance should be activated
- Energy history information
- Total load of the house
- Grid consumption
- Feed-in
- Load of the planned household appliances
- Charge level of the battery storage
- Charge level of the electric car
- Active losses
- Cost information
- Revenue per hour (when fed into the grid)
- Total costs per hour (when drawn from the grid)
- Overall balance (revenue-costs)
- Cost development
If required, the simulation result can also be created and downloaded in graphical
form as a PDF from EOS.
## Integration
The Akkudoktor EOS can be integrated into a wide variety of systems with a variety
of components.
![Integration](../_static/introduction/integration.png)
However, the components are not integrated by the EOS itself, but must be integrated by
the user using an integration solution and currently requires some effort and technical
know-how.
Any [integration](#integration-page) solution that can act as an intermediary between the
components and the REST API of EOS can be used. One possible solution that enables the
integration of components and EOS is Node-RED. Another solution could be Home Assistant
usings its built in features.
Access to the data and functions of the components can be done in a variety of ways.
Node-RED offers a large number of types of nodes that allow access via the protocols
commonly used in this area, such as Modbus or MQTT. Access to any existing databases,
such as InfluxDB or PostgreSQL, is also possible via nodes provided by Node-RED.
It becomes easier if a smart home solution like Home Assistant, openHAB or ioBroker or
solutions such as evcc or openWB are already in use. In this case, these smart home
solutions already take over the technical integration and communication with the components
at a technical level and Node-RED offers nodes for accessing these solutions, so that the
corresponding sources can be easily integrated into a flow.
In Home Assistant you could use an automation to prepare the input payload for EOS and
then use the RESTful integration to call EOS. Based on this concept there is already a
Home Assistant add-on created by [Duetting](#duetting-solution).
The plan created by EOS must also be executed via the chosen integration solution,
with the respective devices receiving their instructions according to the plan.
## Limitations
The plan calculated by EOS is cost-optimized due to the genetic algorithm used, but not
necessarily cost-optimal, since genetic algorithms do not always find the global optimum,
but usually find good local optima very quickly in a large solution space.
## Links
- [German Videos explaining the basic concept and installation process of EOS (YouTube)](https://www.youtube.com/playlist?list=PL8_vk9A-s7zLD865Oou6y3EeQLlNtu-Hn)
- [German Forum of Akkudoktor EOS](https://akkudoktor.net/c/der-akkudoktor/eos)
- [Akkudoktor-EOS GitHub Repository](https://github.com/Akkudoktor-EOS/EOS)
- [Latest EOS Documentation](https://akkudoktor-eos.readthedocs.io/en/latest/)

View File

@@ -1,75 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
(logging-page)=
# Logging
EOS automatically records important events and messages to help you understand whats happening and
to troubleshoot problems.
## How Logging Works
- By default, logs are shown in your terminal (console).
- You can also save logs to a file for later review.
- Log files are rotated automatically to avoid becoming too large.
## Controlling Log Details
### 1. Command-Line Option
Set the amount of log detail shown on the console by using `--log-level` when starting EOS.
Example:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
.venv\Scripts\python src/akkudoktoreos/server/eos.py --log-level DEBUG
.. tab:: Linux
.. code-block:: bash
.venv/bin/python src/akkudoktoreos/server/eos.py --log-level DEBUG
```
Common levels:
- DEBUG (most detail)
- INFO (default)
- WARNING
- ERROR
- CRITICAL (least detail)
### 2. Configuration File
You can also set logging options in your EOS configuration file (EOS.config.json).
```Json
{
"logging": {
"console_level": "INFO",
"file_level": "DEBUG"
}
}
```
### 3. Environment Variable
You can also control the log level by setting the `EOS_LOGGING__CONSOLE_LEVEL` and the
`EOS_LOGGING__FILE_LEVEL` environment variables.
```bash
EOS_LOGGING__CONSOLE_LEVEL="INFO"
EOS_LOGGING__FILE_LEVEL="DEBUG"
```
## File Logging
If the `file_level` configuration is set, log records are written to a rotating log file. The log
file is in the data output directory and named `eos.log`. You may directly read the file or use
the `/v1/logging/log` endpoint to access the file log.

View File

@@ -5,9 +5,9 @@
Measurements are utilized to refine predictions using real data from your system, thereby enhancing
accuracy.
- Household Load Measurement
- Grid Export Measurement
- Grid Import Measurement
- **Household Load Measurement**
- **Grid Export Measurement**
- **Grid Import Measurement**
## Storing Measurements

View File

@@ -2,207 +2,7 @@
# Optimization
## Introduction
The `POST /optimize` API endpoint optimizes your energy management system based on various inputs
including electricity prices, battery storage capacity, PV forecast, and temperature data.
## Input Payload
### Sample Request
```json
{
"ems": {
"preis_euro_pro_wh_akku": 0.0007,
"einspeiseverguetung_euro_pro_wh": 0.00007,
"gesamtlast": [500, 500, ..., 500, 500],
"pv_prognose_wh": [300, 0, 0, ..., 2160, 1840],
"strompreis_euro_pro_wh": [0.0003784, 0.0003868, ..., 0.00034102, 0.00033709]
},
"pv_akku": {
"device_id": "battery1",
"capacity_wh": 12000,
"charging_efficiency": 0.92,
"discharging_efficiency": 0.92,
"max_charge_power_w": 5700,
"initial_soc_percentage": 66,
"min_soc_percentage": 5,
"max_soc_percentage": 100
},
"inverter": {
"device_id": "inverter1",
"max_power_wh": 15500
"battery_id": "battery1",
},
"eauto": {
"device_id": "auto1",
"capacity_wh": 64000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"max_charge_power_w": 11040,
"initial_soc_percentage": 98,
"min_soc_percentage": 60,
"max_soc_percentage": 100
},
"temperature_forecast": [18.3, 18, ..., 20.16, 19.84],
"start_solution": null
}
```
## Input Parameters
### Energy Management System (EMS)
#### Battery Cost (`preis_euro_pro_wh_akku`)
- Unit: €/Wh
- Purpose: Represents the residual value of energy stored in the battery
- Impact: Lower values encourage battery depletion, higher values preserve charge at the end of the simulation.
#### Feed-in Tariff (`einspeiseverguetung_euro_pro_wh`)
- Unit: €/Wh
- Purpose: Compensation received for feeding excess energy back to the grid
#### Total Load Forecast (`gesamtlast`)
- Unit: W
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
- Format: Array of hourly values
- Note: Exclude optimizable loads (EV charging, battery charging, etc.)
##### Data Sources
1. Standard Load Profile: `GET /v1/prediction/list?key=load_mean` for a standard load profile based
on your yearly consumption.
2. Adjusted Load Profile: `GET /v1/prediction/list?key=load_mean_adjusted` for a combination of a
standard load profile based on your yearly consumption incl. data from last 48h.
#### PV Generation Forecast (`pv_prognose_wh`)
- Unit: W
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
- Format: Array of hourly values
- Data Source: `GET /v1/prediction/series?key=pvforecast_ac_power`
#### Electricity Price Forecast (`strompreis_euro_pro_wh`)
- Unit: €/Wh
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
- Format: Array of hourly values
- Data Source: `GET /v1/prediction/list?key=elecprice_marketprice_wh`
Verify prices against your local tariffs.
### Battery Storage System
#### Configuration
- `device_id`: ID of battery
- `capacity_wh`: Total battery capacity in Wh
- `charging_efficiency`: Charging efficiency (0-1)
- `discharging_efficiency`: Discharging efficiency (0-1)
- `max_charge_power_w`: Maximum charging power in W
#### State of Charge (SoC)
- `initial_soc_percentage`: Current battery level (%)
- `min_soc_percentage`: Minimum allowed SoC (%)
- `max_soc_percentage`: Maximum allowed SoC (%)
### Inverter
- `device_id`: ID of inverter
- `max_power_wh`: Maximum inverter power in Wh
- `battery_id`: ID of battery
### Electric Vehicle (EV)
- `device_id`: ID of electric vehicle
- `capacity_wh`: Battery capacity in Wh
- `charging_efficiency`: Charging efficiency (0-1)
- `discharging_efficiency`: Discharging efficiency (0-1)
- `max_charge_power_w`: Maximum charging power in W
- `initial_soc_percentage`: Current charge level (%)
- `min_soc_percentage`: Minimum allowed SoC (%)
- `max_soc_percentage`: Maximum allowed SoC (%)
### Temperature Forecast
- Unit: °C
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
- Format: Array of hourly values
- Data Source: `GET /v1/prediction/list?key=weather_temp_air`
## Output Format
### Sample Response
```json
{
"ac_charge": [0.625, 0, ..., 0.75, 0],
"dc_charge": [1, 1, ..., 1, 1],
"discharge_allowed": [0, 0, 1, ..., 0, 0],
"eautocharge_hours_float": [0.625, 0, ..., 0.75, 0],
"result": {
"Last_Wh_pro_Stunde": [...],
"EAuto_SoC_pro_Stunde": [...],
"Einnahmen_Euro_pro_Stunde": [...],
"Gesamt_Verluste": 1514.96,
"Gesamtbilanz_Euro": 2.51,
"Gesamteinnahmen_Euro": 2.88,
"Gesamtkosten_Euro": 5.39,
"akku_soc_pro_stunde": [...]
}
}
```
### Output Parameters
#### Battery Control
- `ac_charge`: Grid charging schedule (0-1)
- `dc_charge`: DC charging schedule (0-1)
- `discharge_allowed`: Discharge permission (0 or 1)
0 (no charge)
1 (charge with full load)
`ac_charge` multiplied by the maximum charge power of the battery results in the planned charging power.
#### EV Charging
- `eautocharge_hours_float`: EV charging schedule (0-1)
#### Results
The `result` object contains detailed information about the optimization outcome.
The length of the array is between 25 and 48 and starts at the current hour and ends at 23:00 tomorrow.
- `Last_Wh_pro_Stunde`: Array of hourly load values in Wh
- Shows the total energy consumption per hour
- Includes household load, battery charging/discharging, and EV charging
- `EAuto_SoC_pro_Stunde`: Array of hourly EV state of charge values (%)
- Shows the projected EV battery level throughout the optimization period
- `Einnahmen_Euro_pro_Stunde`: Array of hourly revenue values in Euro
- `Gesamt_Verluste`: Total energy losses in Wh
- `Gesamtbilanz_Euro`: Overall financial balance in Euro
- `Gesamteinnahmen_Euro`: Total revenue in Euro
- `Gesamtkosten_Euro`: Total costs in Euro
- `akku_soc_pro_stunde`: Array of hourly battery state of charge values (%)
## Timeframe overview
```{figure} ../_static/optimization_timeframes.png
:alt: Timeframe Overview
Timeframe Overview
```
:::{admonition} Todo
:class: note
Describe optimization.
:::

View File

@@ -1,15 +1,14 @@
% SPDX-License-Identifier: Apache-2.0
(prediction-page)=
# Predictions
Predictions, along with simulations and measurements, form the foundation upon which energy
optimization is executed. In EOS, a standard set of predictions is managed, including:
- Household Load Prediction
- Electricity Price Prediction
- PV Power Prediction
- Weather Prediction
- **Household Load Prediction**
- **Electricity Price Prediction**
- **PV Power Prediction**
- **Weather Prediction**
## Storing Predictions
@@ -61,15 +60,13 @@ A dictionary with the following structure:
#### 2. DateTimeDataFrame
A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html) dataframe with a
`DatetimeIndex`. Use
[pandas.DataFrame.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_json.html#pandas.DataFrame.to_json).
`DatetimeIndex`. Use [pandas.DataFrame.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_json.html#pandas.DataFrame.to_json).
The column name of the data must be the same as the names of the `prediction key`s.
#### 3. DateTimeSeries
A JSON string created from a [pandas](https://pandas.pydata.org/docs/index.html) series with a
`DatetimeIndex`. Use
[pandas.Series.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.Series.to_json.html#pandas.Series.to_json).
`DatetimeIndex`. Use [pandas.Series.to_json(orient="index")](https://pandas.pydata.org/docs/reference/api/pandas.Series.to_json.html#pandas.Series.to_json).
## Adjusted Predictions
@@ -119,11 +116,9 @@ Configuration options:
- `provider`: Electricity price provider id of provider to be used.
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
- `ElecPriceEnergyCharts`: Retrieves from Energy-Charts.info.
- `ElecPriceImport`: Imports from a file or JSON string.
- `charges_kwh`: Electricity price charges (€/kWh).
- `vat_rate`: VAT rate factor applied to electricity price when charges are used (default: 1.19).
- `provider_settings.import_file_path`: Path to the file to import electricity price forecast data from.
- `provider_settings.import_json`: JSON string, dictionary of electricity price forecast value lists.
@@ -135,24 +130,6 @@ prices by extrapolating historical price data combined with the most recent actu
from Akkudoktor.net. Electricity price charges given in the `charges_kwh` configuration
option are added.
### ElecPriceEnergyCharts Provider
The `ElecPriceEnergyCharts` provider retrieves day-ahead electricity market prices from
[Energy-Charts.info](https://www.Energy-Charts.info). It supports both short-term and extended forecasting by combining
real-time market data with historical price trends.
- For the next 24 hours, market prices are fetched directly from Energy-Charts.info.
- For periods beyond 24 hours, prices are estimated using extrapolation based on historical data and the latest
available market values.
Charges and VAT
- If `charges_kwh` configuration option is greater than 0, the electricity price is calculated as:
`(market price + charges_kwh) * vat_rate` where `vat_rate` is configurable (default: 1.19 for 19% VAT).
- If `charges_kwh` is set to 0, the electricity price is simply: `market_price` (no VAT applied).
**Note:** For the most accurate forecasts, it is recommended to set the `historic_hours` parameter to 840.
### ElecPriceImport Provider
The `ElecPriceImport` provider is designed to import electricity prices from a file or a JSON
@@ -164,12 +141,9 @@ The prediction key for the electricity price forecast data is:
- `elecprice_marketprice_wh`: Electricity market price per Wh (€/Wh).
The electricity proce forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source can be given in the
<project:#prediction-import-providers>. The data source must be given in the
`import_file_path` or `import_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/ElecPriceImport` endpoint.
## Load Prediction
Prediction keys:
@@ -185,7 +159,6 @@ Configuration options:
- `provider`: Load provider id of provider to be used.
- `LoadAkkudoktor`: Retrieves from local database.
- `LoadVrm`: Retrieves data from the VRM API by Victron Energy.
- `LoadImport`: Imports from a file or JSON string.
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
@@ -198,27 +171,6 @@ The `LoadAkkudoktor` provider retrieves generic load data from a local database
align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration
option.
### LoadVrm Provider
The `LoadVrm` provider retrieves load forecast data from the VRM API by Victron Energy.
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
```python
{
"load": {
"provider": "LoadVrm",
"provider_settings": {
"load_vrm_token": "dummy-token",
"load_vrm_idsite": 12345
}
```
The prediction keys for the load forecast data are:
- `load_mean`: Predicted load mean value (W).
### LoadImport Provider
The `LoadImport` provider is designed to import load forecast data from a file or a JSON
@@ -232,12 +184,9 @@ The prediction keys for the load forecast data are:
- `load_mean_adjusted`: Predicted load mean value adjusted by load measurement (W).
The load forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source can be given in the `loadimport_file_path`
<project:#prediction-import-providers>. The data source must be given in the `loadimport_file_path`
or `loadimport_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/LoadImport` endpoint.
## PV Power Prediction
Prediction keys:
@@ -257,25 +206,16 @@ Configuration options:
- `provider`: PVForecast provider id of provider to be used.
- `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net.
- `PVForecastVrm`: Retrieves data from the VRM API by Victron Energy.
- `PVForecastImport`: Imports from a file or JSON string.
- `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
Clockwise from north (north=0, east=90, south=180, west=270).
- `planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `planes[].peakpower`: Nominal power of PV system in kW.
- `planes[].pvtechchoice`: PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'.
- `planes[].mountingplace`: Type of mounting for PV system.
Options are 'free' for free-standing and 'building' for building-integrated.
- `planes[].mountingplace`: Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.
- `planes[].loss`: Sum of PV system losses in percent
- `planes[].trackingtype`: Type of suntracking.
0=fixed,
1=single horizontal axis aligned north-south,
2=two-axis tracking,
3=vertical axis tracking,
4=single horizontal axis aligned east-west,
5=single inclined axis aligned north-south.
- `planes[].trackingtype`: Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south.
- `planes[].optimal_surface_tilt`: Calculate the optimum tilt angle. Ignored for two-axis tracking.
- `planes[].optimalangles`: Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking.
- `planes[].albedo`: Proportion of the light hitting the ground that it reflects back.
@@ -287,73 +227,39 @@ Configuration options:
- `provider_settings.import_file_path`: Path to the file to import PV forecast data from.
- `provider_settings.import_json`: JSON string, dictionary of PV forecast value lists.
---
------
Detailed definitions taken from
[PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en).
Some of the planes configuration options directly follow the [PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en) nomenclature.
Detailed definitions taken from **PVGIS**:
- `pvtechchoice`
The performance of PV modules depends on the temperature and on the solar irradiance, but the exact
dependence varies between different types of PV modules. At the moment we can estimate the losses
due to temperature and irradiance effects for the following types of modules: crystalline silicon
cells; thin film modules made from CIS or CIGS and thin film modules made from Cadmium Telluride
(CdTe).
The performance of PV modules depends on the temperature and on the solar irradiance, but the exact dependence varies between different types of PV modules. At the moment we can estimate the losses due to temperature and irradiance effects for the following types of modules: crystalline silicon cells; thin film modules made from CIS or CIGS and thin film modules made from Cadmium Telluride (CdTe).
For other technologies (especially various amorphous technologies), this correction cannot be
calculated here. If you choose one of the first three options here the calculation of performance
will take into account the temperature dependence of the performance of the chosen technology. If
you choose the other option (other/unknown), the calculation will assume a loss of 8% of power due
to temperature effects (a generic value which has found to be reasonable for temperate climates).
For other technologies (especially various amorphous technologies), this correction cannot be calculated here. If you choose one of the first three options here the calculation of performance will take into account the temperature dependence of the performance of the chosen technology. If you choose the other option (other/unknown), the calculation will assume a loss of 8% of power due to temperature effects (a generic value which has found to be reasonable for temperate climates).
PV power output also depends on the spectrum of the solar radiation. PVGIS can calculate how the
variations of the spectrum of sunlight affects the overall energy production from a PV system. At
the moment this calculation can be done for crystalline silicon and CdTe modules. Note that this
calculation is not yet available when using the NSRDB solar radiation database.
PV power output also depends on the spectrum of the solar radiation. PVGIS can calculate how the variations of the spectrum of sunlight affects the overall energy production from a PV system. At the moment this calculation can be done for crystalline silicon and CdTe modules. Note that this calculation is not yet available when using the NSRDB solar radiation database.
- `peakpower`
This is the power that the manufacturer declares that the PV array can produce under standard test
conditions (STC), which are a constant 1000W of solar irradiation per square meter in the plane of
the array, at an array temperature of 25°C. The peak power should be entered in kilowatt-peak (kWp).
If you do not know the declared peak power of your modules but instead know the area of the modules
and the declared conversion efficiency (in percent), you can calculate the peak power as
power = area \* efficiency / 100.
This is the power that the manufacturer declares that the PV array can produce under standard test conditions (STC), which are a constant 1000W of solar irradiation per square meter in the plane of the array, at an array temperature of 25°C. The peak power should be entered in kilowatt-peak (kWp). If you do not know the declared peak power of your modules but instead know the area of the modules and the declared conversion efficiency (in percent), you can calculate the peak power as power = area * efficiency / 100.
Bifacial modules: PVGIS doesn't make specific calculations for bifacial modules at present. Users
who wish to explore the possible benefits of this technology can input the power value for Bifacial
Nameplate Irradiance. This can also be can also be estimated from the front side peak power P_STC
value and the bifaciality factor, φ (if reported in the module data sheet) as:
P_BNPI = P_STC \* (1 + φ \* 0.135). NB this bifacial approach is not appropriate for BAPV or BIPV
installations or for modules mounting on a N-S axis i.e. facing E-W.
Bifacial modules: PVGIS doesn't make specific calculations for bifacial modules at present. Users who wish to explore the possible benefits of this technology can input the power value for Bifacial Nameplate Irradiance. This can also be can also be estimated from the front side peak power P_STC value and the bifaciality factor, φ (if reported in the module data sheet) as: P_BNPI = P_STC * (1 + φ * 0.135). NB this bifacial approach is not appropriate for BAPV or BIPV installations or for modules mounting on a N-S axis i.e. facing E-W.
- `loss`
The estimated system losses are all the losses in the system, which cause the power actually
delivered to the electricity grid to be lower than the power produced by the PV modules. There are
several causes for this loss, such as losses in cables, power inverters, dirt (sometimes snow) on
the modules and so on. Over the years the modules also tend to lose a bit of their power, so the
average yearly output over the lifetime of the system will be a few percent lower than the output
in the first years.
The estimated system losses are all the losses in the system, which cause the power actually delivered to the electricity grid to be lower than the power produced by the PV modules. There are several causes for this loss, such as losses in cables, power inverters, dirt (sometimes snow) on the modules and so on. Over the years the modules also tend to lose a bit of their power, so the average yearly output over the lifetime of the system will be a few percent lower than the output in the first years.
We have given a default value of 14% for the overall losses. If you have a good idea that your value
will be different (maybe due to a really high-efficiency inverter) you may reduce this value a little.
We have given a default value of 14% for the overall losses. If you have a good idea that your value will be different (maybe due to a really high-efficiency inverter) you may reduce this value a little.
- `mountingplace`
For fixed (non-tracking) systems, the way the modules are mounted will have an influence on the
temperature of the module, which in turn affects the efficiency. Experiments have shown that if the
movement of air behind the modules is restricted, the modules can get considerably hotter
(up to 15°C at 1000W/m2 of sunlight).
For fixed (non-tracking) systems, the way the modules are mounted will have an influence on the temperature of the module, which in turn affects the efficiency. Experiments have shown that if the movement of air behind the modules is restricted, the modules can get considerably hotter (up to 15°C at 1000W/m2 of sunlight).
In PVGIS there are two possibilities: free-standing, meaning that the modules are mounted on a rack
with air flowing freely behind the modules; and building- integrated, which means that the modules
are completely built into the structure of the wall or roof of a building, with no air movement
behind the modules.
In PVGIS there are two possibilities: free-standing, meaning that the modules are mounted on a rack with air flowing freely behind the modules; and building- integrated, which means that the modules are completely built into the structure of the wall or roof of a building, with no air movement behind the modules.
Some types of mounting are in between these two extremes, for instance if the modules are mounted on
a roof with curved roof tiles, allowing air to move behind the modules. In such cases, the
performance will be somewhere between the results of the two calculations that are possible here.
Some types of mounting are in between these two extremes, for instance if the modules are mounted on a roof with curved roof tiles, allowing air to move behind the modules. In such cases, the performance will be somewhere between the results of the two calculations that are possible here.
- `userhorizon`
@@ -365,10 +271,9 @@ represent equal angular distance around the horizon. For instance, if you have 3
point is due north, the next is 10 degrees east of north, and so on, until the last point, 10
degrees west of north.
---
------
Most of the configuration options are in line with the
[PVLib](https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html) definition for PVGIS data.
Most of the planes configuration options are in line with the [PVLib](https://pvlib-python.readthedocs.io/en/stable/_modules/pvlib/iotools/pvgis.html) definition for PVGIS data.
Detailed definitions from **PVLib** for PVGIS data.
@@ -381,7 +286,7 @@ Tilt angle from horizontal plane.
Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180,
west=270). This is offset 180 degrees from the convention used by PVGIS.
---
------
### PVForecastAkkudoktor Provider
@@ -396,8 +301,7 @@ The following prediction configuration options of the PV system must be set:
For each plane of the PV system the following configuration options must be set:
- `pvforecast.planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `pvforecast.planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast.planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).
- `pvforecast.planes[].userhorizon`: Elevation of horizon in degrees, at equally spaced azimuth clockwise from north.
- `pvforecast.planes[].inverter_paco`: AC power rating of the inverter. [W]
- `pvforecast.planes[].peakpower`: Nominal power of PV system in kW.
@@ -418,56 +322,34 @@ Example:
"surface_azimuth": -10,
"surface_tilt": 7,
"userhorizon": [20, 27, 22, 20],
"inverter_paco": 10000
"inverter_paco": 10000,
},
{
"peakpower": 4.8,
"surface_azimuth": -90,
"surface_tilt": 7,
"userhorizon": [30, 30, 30, 50],
"inverter_paco": 10000
"inverter_paco": 10000,
},
{
"peakpower": 1.4,
"surface_azimuth": -40,
"surface_tilt": 60,
"userhorizon": [60, 30, 0, 30],
"inverter_paco": 2000
"inverter_paco": 2000,
},
{
"peakpower": 1.6,
"surface_azimuth": 5,
"surface_tilt": 45,
"userhorizon": [45, 25, 30, 60],
"inverter_paco": 1400
"inverter_paco": 1400,
}
]
}
}
```
### PVForecastVrm Provider
The `PVForecastVrm` provider retrieves pv power forecast data from the VRM API by Victron Energy.
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
```python
{
"pvforecast": {
"provider": "PVForecastVrm",
"provider_settings": {
"pvforecast_vrm_token": "dummy-token",
"pvforecast_vrm_idsite": 12345
}
}
```
The prediction keys for the PV forecast data are:
- `pvforecast_dc_power`: Total DC power (W).
### PVForecastImport Provider
The `PVForecastImport` provider is designed to import PV forecast data from a file or a JSON
@@ -476,16 +358,13 @@ becomes available.
The prediction keys for the PV forecast data are:
- `pvforecast_ac_power`: Total AC power (W).
- `pvforecast_dc_power`: Total DC power (W).
- `pvforecast_ac_power`: Total DC power (W).
- `pvforecast_dc_power`: Total AC power (W).
The PV forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source can be given in the
<project:#prediction-import-providers>. The data source must be given in the
`import_file_path` or `import_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/PVForecastImport` endpoint.
## Weather Prediction
Prediction keys:
@@ -519,8 +398,8 @@ Configuration options:
- `provider`: Load provider id of provider to be used.
- `BrightSky`: Retrieves from [BrightSky](https://api.brightsky.dev).
- `ClearOutside`: Retrieves from [ClearOutside](https://clearoutside.com/forecast).
- `BrightSky`: Retrieves from https://api.brightsky.dev.
- `ClearOutside`: Retrieves from https://clearoutside.com/forecast.
- `LoadImport`: Imports from a file or JSON string.
- `provider_settings.import_file_path`: Path to the file to import weatherforecast data from.
@@ -581,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
becomes available.
The prediction keys for the weather forecast data are:
The prediction keys for the PV forecast data are:
- `weather_dew_point`: Dew Point (°C)
- `weather_dhi`: Diffuse Horizontal Irradiance (W/m2)
@@ -607,8 +486,5 @@ The prediction keys for the weather forecast data are:
- `weather_wind_speed`: Wind Speed (kmph)
The PV forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>. The data source can be given in the
<project:#prediction-import-providers>. The data source must be given in the
`import_file_path` or `import_json` configuration option.
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/WeatherImport` endpoint.

View File

@@ -19,7 +19,6 @@ Install the dependencies in a virtual environment:
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pip install -e .
.. tab:: Linux
@@ -27,7 +26,6 @@ Install the dependencies in a virtual environment:
python -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e .
```
@@ -75,53 +73,37 @@ This project uses the `EOS.config.json` file to manage configuration settings.
### Default Configuration
A default configuration file `default.config.json` is provided. This file contains all the necessary
configuration keys with their default values.
A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values.
### Custom Configuration
Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`.
- If the directory specified by `EOS_DIR` contains an existing `EOS.config.json` file, the
application will use this configuration file.
- If the `EOS.config.json` file does not exist in the specified directory, the `default.config.json`
file will be copied to the directory as `EOS.config.json`.
- If the directory specified by `EOS_DIR` contains an existing `EOS.config.json` file, the application will use this configuration file.
- If the `EOS.config.json` file does not exist in the specified directory, the `default.config.json` file will be copied to the directory as `EOS.config.json`.
### Configuration Updates
If the configuration keys in the `EOS.config.json` file are missing or different from those in
`default.config.json`, they will be automatically updated to match the default settings, ensuring
that all required keys are present.
If the configuration keys in the `EOS.config.json` file are missing or different from those in `default.config.json`, they will be automatically updated to match the default settings, ensuring that all required keys are present.
## Classes and Functionalities
This project uses various classes to simulate and optimize the components of an energy system. Each
class represents a specific aspect of the system, as described below:
This project uses various classes to simulate and optimize the components of an energy system. Each class represents a specific aspect of the system, as described below:
- `Battery`: Simulates a battery storage system, including capacity, state of charge, and now
charge and discharge losses.
- `Battery`: Simulates a battery storage system, including capacity, state of charge, and now charge and discharge losses.
- `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and
historical generation data.
- `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and historical generation data.
- `Load`: Models the load requirements of a household or business, enabling the prediction of future
energy demand.
- `Load`: Models the load requirements of a household or business, enabling the prediction of future energy demand.
- `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various
operating conditions.
- `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various operating conditions.
- `Strompreis`: Provides information on electricity prices, enabling optimization of energy
consumption and generation based on tariff information.
- `Strompreis`: Provides information on electricity prices, enabling optimization of energy consumption and generation based on tariff information.
- `EMS`: The Energy Management System (EMS) coordinates the interaction between the various
components, performs optimization, and simulates the operation of the entire energy system.
- `EMS`: The Energy Management System (EMS) coordinates the interaction between the various components, performs optimization, and simulates the operation of the entire energy system.
These classes work together to enable a detailed simulation and optimization of the energy system.
For each class, specific parameters and settings can be adjusted to test different scenarios and
strategies.
These classes work together to enable a detailed simulation and optimization of the energy system. For each class, specific parameters and settings can be adjusted to test different scenarios and strategies.
### Customization and Extension
Each class is designed to be easily customized and extended to integrate additional functions or
improvements. For example, new methods can be added for more accurate modeling of PV system or
battery behavior. Developers are invited to modify and extend the system according to their needs.
Each class is designed to be easily customized and extended to integrate additional functions or improvements. For example, new methods can be added for more accurate modeling of PV system or battery behavior. Developers are invited to modify and extend the system according to their needs.

View File

@@ -8,45 +8,23 @@
```{toctree}
:maxdepth: 2
:caption: Overview
akkudoktoreos/introduction.md
```
```{toctree}
:maxdepth: 2
:caption: Tutorials
:caption: 'Contents:'
welcome.md
akkudoktoreos/about.md
develop/getting_started.md
```
```{toctree}
:maxdepth: 2
:caption: How-To Guides
develop/CONTRIBUTING.md
```
```{toctree}
:maxdepth: 2
:caption: Reference
akkudoktoreos/architecture.md
akkudoktoreos/configuration.md
akkudoktoreos/optimization.md
akkudoktoreos/prediction.md
akkudoktoreos/measurement.md
akkudoktoreos/integration.md
akkudoktoreos/logging.md
akkudoktoreos/serverapi.md
akkudoktoreos/api.rst
```
## Indices and tables
# Indices and tables
- {ref}`genindex`
- {ref}`modindex`

View File

@@ -1,20 +0,0 @@
{
"plugins": {
"md007": {
"enabled": true,
"code_block_line_length" : 160
},
"md013": {
"enabled": true,
"line_length" : 120
},
"md041": {
"enabled": false
}
},
"extensions": {
"front-matter" : {
"enabled" : true
}
}
}

View File

@@ -1,12 +1,12 @@
% SPDX-License-Identifier: Apache-2.0
# Welcome to the EOS documentation
# Welcome to the EOS documentation!
This documentation is continuously written. It is edited via text files in the
[Markdown/ Markedly Structured Text](https://myst-parser.readthedocs.io/en/latest/index.html)
markup language and then compiled into a static website/ offline document using the open source tool
[Sphinx](https://www.sphinx-doc.org) and is available on
[Read the Docs](https://akkudoktor-eos.readthedocs.io/en/latest/).
[Sphinx](https://www.sphinx-doc.org) and will someday land on
[Read the Docs](https://akkudoktoreos.readthedocs.io/en/latest/index.html).
You can contribute to EOS's documentation by opening
[GitHub issues](https://github.com/Akkudoktor-EOS/EOS/issues)

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ authors = [
description = "This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period."
readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.11"
requires-python = ">=3.10"
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
@@ -43,18 +43,12 @@ profile = "black"
[tool.ruff]
line-length = 100
exclude = [
"tests",
"scripts",
]
output-format = "full"
[tool.ruff.lint]
select = [
"F", # Enable all `Pyflakes` rules.
"D", # Enable all `pydocstyle` rules, limiting to those that adhere to the
# Google convention via `convention = "google"`, below.
"S", # Enable all `flake8-bandit` rules.
]
ignore = [
# Prevent errors due to ruff false positives

View File

@@ -1,15 +1,14 @@
-r requirements.txt
gitlint==0.19.1
GitPython==3.1.45
myst-parser==4.0.1
sphinx==8.2.3
gitpython==3.1.44
linkify-it-py==2.0.3
myst-parser==4.0.0
sphinx==8.1.3
sphinx_rtd_theme==3.0.2
sphinx-tabs==3.4.7
pymarkdownlnt==0.9.32
pytest==8.4.2
pytest-cov==7.0.0
pytest==8.3.4
pytest-cov==6.0.0
pytest-xprocess==1.0.2
pre-commit
mypy==1.18.2
types-requests==2.32.4.20250913
pandas-stubs==2.3.2.250827
mypy==1.13.0
types-requests==2.32.0.20241016
pandas-stubs==2.2.3.241126

View File

@@ -1,25 +1,17 @@
cachebox==5.0.2
numpy==2.3.3
numpydantic==1.6.11
matplotlib==3.10.6
fastapi[standard]==0.115.14
python-fasthtml==0.12.29
MonsterUI==1.0.29
markdown-it-py==3.0.0
mdit-py-plugins==0.5.0
bokeh==3.8.0
uvicorn==0.36.0
scikit-learn==1.7.2
timezonefinder==7.0.2
deap==1.4.3
requests==2.32.5
pandas==2.3.2
pendulum==3.1.0
platformdirs==4.4.0
psutil==7.1.0
pvlib==0.13.1
pydantic==2.11.9
statsmodels==0.14.5
pydantic-settings==2.11.0
linkify-it-py==2.0.3
loguru==0.7.3
numpy==2.2.2
numpydantic==1.6.7
matplotlib==3.10.0
fastapi[standard]==0.115.7
python-fasthtml==0.12.0
uvicorn==0.34.0
scikit-learn==1.6.1
timezonefinder==6.5.8
deap==1.4.2
requests==2.32.3
pandas==2.2.3
pendulum==3.0.0
platformdirs==4.3.6
pvlib==0.11.2
pydantic==2.10.6
statsmodels==0.14.4
pydantic-settings==2.7.0

View File

@@ -150,7 +150,7 @@ def main():
try:
if args.input_file:
with open(args.input_file, "r", encoding="utf-8", newline=None) as f:
with open(args.input_file, "r", encoding="utf8") as f:
content = f.read()
elif args.input:
content = args.input
@@ -164,7 +164,7 @@ def main():
)
if args.output_file:
# Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
with open(args.output_file, "w", encoding="utf8") as f:
f.write(extracted_content)
else:
# Write to std output

View File

@@ -3,21 +3,22 @@
import argparse
import json
import os
import re
import sys
import textwrap
from pathlib import Path
from typing import Any, Union
from loguru import logger
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from akkudoktoreos.config.config import ConfigEOS, GeneralSettings, get_config
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.utils.docs import get_model_structure_from_examples
logger = get_logger(__name__)
documented_types: set[PydanticBaseModel] = set()
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
@@ -85,7 +86,7 @@ def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_f
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"):
type_name = field_type.__name__
return type_name
@@ -143,7 +144,6 @@ def generate_config_table_md(
field_type = field_info.annotation if regular_field else field_info.return_type
default_value = get_default_value(field_info, regular_field)
description = field_info.description if field_info.description else "-"
deprecated = field_info.deprecated if field_info.deprecated else None
read_only = "rw" if regular_field else "ro"
type_name = get_type_name(field_type)
@@ -153,11 +153,6 @@ def generate_config_table_md(
env_entry = f"| `{prefix}{config_name}` "
else:
env_entry = "| "
if deprecated:
if isinstance(deprecated, bool):
description = "Deprecated!"
else:
description = deprecated
table += f"| {field_name} {env_entry}| `{type_name}` | `{read_only}` | `{default_value}` | {description} |\n"
inner_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
@@ -263,7 +258,7 @@ def generate_config_md(config_eos: ConfigEOS) -> str:
markdown = "# Configuration Table\n\n"
# Generate tables for each top level config
for field_name, field_info in config_eos.__class__.model_fields.items():
for field_name, field_info in config_eos.model_fields.items():
field_type = field_info.annotation
markdown += generate_config_table_md(
field_type, [field_name], f"EOS_{field_name.upper()}__", True
@@ -283,13 +278,6 @@ def generate_config_md(config_eos: ConfigEOS) -> str:
markdown = markdown.rstrip("\n")
markdown += "\n"
# Assure log path does not leak to documentation
markdown = re.sub(
r'(?<=["\'])/[^"\']*/output/eos\.log(?=["\'])',
'/home/user/.local/share/net.akkudoktoreos.net/output/eos.log',
markdown
)
return markdown
@@ -308,11 +296,9 @@ def main():
try:
config_md = generate_config_md(config_eos)
if os.name == "nt":
config_md = config_md.replace("\\\\", "/")
if args.output_file:
# Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
with open(args.output_file, "w", encoding="utf8") as f:
f.write(config_md)
else:
# Write to std output

View File

@@ -16,7 +16,6 @@ Example:
import argparse
import json
import os
import sys
from fastapi.openapi.utils import get_openapi
@@ -42,9 +41,6 @@ def generate_openapi() -> dict:
general = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["general"]["default"]
general["config_file_path"] = "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
general["config_folder_path"] = "/home/user/.config/net.akkudoktoreos.net"
# Fix file path for logging settings to not show local/test file path
logging = openapi_spec["components"]["schemas"]["ConfigEOS"]["properties"]["logging"]["default"]
logging["file_path"] = "/home/user/.local/share/net.akkudoktoreos.net/output/eos.log"
return openapi_spec
@@ -63,7 +59,7 @@ def main():
openapi_spec_str = json.dumps(openapi_spec, indent=2)
if args.output_file:
# Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
with open(args.output_file, "w", encoding="utf8") as f:
f.write(openapi_spec_str)
else:
# Write to std output

View File

@@ -3,7 +3,6 @@
import argparse
import json
import os
import sys
import git
@@ -285,11 +284,9 @@ def main():
try:
openapi_md = generate_openapi_md()
if os.name == "nt":
openapi_md = openapi_md.replace("127.0.0.1", "127.0.0.1")
if args.output_file:
# Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
with open(args.output_file, "w", encoding="utf8") as f:
f.write(openapi_md)
else:
# Write to std output

View File

@@ -1 +0,0 @@
# Placeholder for gitlint user rules (see https://jorisroovers.com/gitlint/latest/rules/user_defined_rules/).

View File

@@ -12,12 +12,15 @@ import numpy as np
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.optimization.genetic import (
OptimizationParameters,
optimization_problem,
)
from akkudoktoreos.prediction.prediction import get_prediction
get_logger(__name__, logging_level="DEBUG")
def prepare_optimization_real_parameters() -> OptimizationParameters:
"""Prepare and return optimization parameters with real world data.

View File

@@ -121,40 +121,30 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
# Initialize the oprediction
config_eos = get_config()
prediction_eos = get_prediction()
if verbose:
print(f"\nProvider ID: {provider_id}")
if provider_id in ("PVForecastAkkudoktor",):
settings = config_pvforecast()
forecast = "pvforecast"
settings["pvforecast"]["provider"] = provider_id
elif provider_id in ("BrightSky", "ClearOutside"):
settings = config_weather()
forecast = "weather"
settings["weather"]["provider"] = provider_id
elif provider_id in ("ElecPriceAkkudoktor",):
settings = config_elecprice()
forecast = "elecprice"
settings["elecprice"]["provider"] = provider_id
elif provider_id in ("LoadAkkudoktor",):
settings = config_elecprice()
forecast = "load"
settings["load"]["loadakkudoktor_year_energy"] = 1000
settings["load"]["provider"] = provider_id
else:
raise ValueError(f"Unknown provider '{provider_id}'.")
settings[forecast]["provider"] = provider_id
config_eos.merge_settings_from_dict(settings)
provider = prediction_eos.provider_by_id(provider_id)
prediction_eos.update_data()
# Return result of prediction
provider = prediction_eos.provider_by_id(provider_id)
if verbose:
print(f"\nProvider ID: {provider.provider_id()}")
print("----------")
print("\nSettings\n----------")
print(settings)
print("\nProvider\n----------")
print(f"elecprice.provider: {config_eos.elecprice.provider}")
print(f"load.provider: {config_eos.load.provider}")
print(f"pvforecast.provider: {config_eos.pvforecast.provider}")
print(f"weather.provider: {config_eos.weather.provider}")
print(f"enabled: {provider.enabled()}")
for key in provider.record_keys:
print(f"\n{key}\n----------")
print(f"Array: {provider.key_to_array(key)}")

View File

@@ -14,7 +14,6 @@ import shutil
from pathlib import Path
from typing import Any, ClassVar, Optional, Type
from loguru import logger
from platformdirs import user_config_dir, user_data_dir
from pydantic import Field, computed_field
from pydantic_settings import (
@@ -23,15 +22,15 @@ from pydantic_settings import (
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from pydantic_settings.sources import ConfigFileSourceMixin
# settings
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cachesettings import CacheCommonSettings
from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.decorators import classproperty
from akkudoktoreos.core.emsettings import EnergyManagementCommonSettings
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.logsettings import LoggingCommonSettings
from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, merge_models
from akkudoktoreos.core.pydantic import merge_models
from akkudoktoreos.devices.settings import DevicesCommonSettings
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
@@ -44,6 +43,8 @@ from akkudoktoreos.server.server import ServerCommonSettings
from akkudoktoreos.utils.datetimeutil import to_timezone
from akkudoktoreos.utils.utils import UtilsCommonSettings
logger = get_logger(__name__)
def get_absolute_path(
basepath: Optional[Path | str], subpath: Optional[Path | str]
@@ -78,6 +79,10 @@ class GeneralSettings(SettingsBaseModel):
Properties:
timezone (Optional[str]): Computed time zone string based on the specified latitude
and longitude.
Validators:
validate_latitude (float): Ensures `latitude` is within the range -90 to 90.
validate_longitude (float): Ensures `longitude` is within the range -180 to 180.
"""
_config_folder_path: ClassVar[Optional[Path]] = None
@@ -91,6 +96,10 @@ class GeneralSettings(SettingsBaseModel):
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(
default=52.52,
ge=-90.0,
@@ -119,6 +128,12 @@ class GeneralSettings(SettingsBaseModel):
"""Compute data_output_path based on data_folder_path."""
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]
@property
def config_folder_path(self) -> Optional[Path]:
@@ -132,68 +147,24 @@ class GeneralSettings(SettingsBaseModel):
return self._config_file_path
class SettingsEOS(BaseSettings, PydanticModelNestedValueMixin):
class SettingsEOS(BaseSettings):
"""Settings for all EOS.
Used by updating the configuration with specific settings only.
"""
general: Optional[GeneralSettings] = Field(
default=None,
description="General Settings",
)
cache: Optional[CacheCommonSettings] = Field(
default=None,
description="Cache Settings",
)
ems: Optional[EnergyManagementCommonSettings] = Field(
default=None,
description="Energy Management Settings",
)
logging: Optional[LoggingCommonSettings] = Field(
default=None,
description="Logging Settings",
)
devices: Optional[DevicesCommonSettings] = Field(
default=None,
description="Devices Settings",
)
measurement: Optional[MeasurementCommonSettings] = Field(
default=None,
description="Measurement Settings",
)
optimization: Optional[OptimizationCommonSettings] = Field(
default=None,
description="Optimization Settings",
)
prediction: Optional[PredictionCommonSettings] = Field(
default=None,
description="Prediction Settings",
)
elecprice: Optional[ElecPriceCommonSettings] = Field(
default=None,
description="Electricity Price Settings",
)
load: Optional[LoadCommonSettings] = Field(
default=None,
description="Load Settings",
)
pvforecast: Optional[PVForecastCommonSettings] = Field(
default=None,
description="PV Forecast Settings",
)
weather: Optional[WeatherCommonSettings] = Field(
default=None,
description="Weather Settings",
)
server: Optional[ServerCommonSettings] = Field(
default=None,
description="Server Settings",
)
utils: Optional[UtilsCommonSettings] = Field(
default=None,
description="Utilities Settings",
)
general: Optional[GeneralSettings] = None
logging: Optional[LoggingCommonSettings] = None
devices: Optional[DevicesCommonSettings] = None
measurement: Optional[MeasurementCommonSettings] = None
optimization: Optional[OptimizationCommonSettings] = None
prediction: Optional[PredictionCommonSettings] = None
elecprice: Optional[ElecPriceCommonSettings] = None
load: Optional[LoadCommonSettings] = None
pvforecast: Optional[PVForecastCommonSettings] = None
weather: Optional[WeatherCommonSettings] = None
server: Optional[ServerCommonSettings] = None
utils: Optional[UtilsCommonSettings] = None
model_config = SettingsConfigDict(
env_nested_delimiter="__",
@@ -210,8 +181,6 @@ class SettingsEOSDefaults(SettingsEOS):
"""
general: GeneralSettings = GeneralSettings()
cache: CacheCommonSettings = CacheCommonSettings()
ems: EnergyManagementCommonSettings = EnergyManagementCommonSettings()
logging: LoggingCommonSettings = LoggingCommonSettings()
devices: DevicesCommonSettings = DevicesCommonSettings()
measurement: MeasurementCommonSettings = MeasurementCommonSettings()
@@ -277,16 +246,6 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
ENCODING: ClassVar[str] = "UTF-8"
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
def __hash__(self) -> int:
# ConfigEOS is a singleton
return hash("config_eos")
def __eq__(self, other: Any) -> bool:
if not isinstance(other, ConfigEOS):
return False
# ConfigEOS is a singleton
return True
@classmethod
def settings_customise_sources(
cls,
@@ -325,13 +284,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
- This method logs a warning if the default configuration file cannot be copied.
- It ensures that a fallback to the default configuration file is always possible.
"""
setting_sources = [
init_settings,
env_settings,
dotenv_settings,
]
file_settings: Optional[JsonConfigSettingsSource] = None
file_settings: Optional[ConfigFileSourceMixin] = None
config_file, exists = cls._get_config_file_path()
config_dir = config_file.parent
if not exists:
@@ -342,21 +295,20 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
logger.warning(f"Could not copy default config: {exc}. Using default config...")
config_file = cls.config_default_file_path
config_dir = config_file.parent
try:
file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file)
setting_sources.append(file_settings)
except Exception as e:
logger.error(
f"Error reading config file '{config_file}' (falling back to default config): {e}"
)
default_settings = JsonConfigSettingsSource(
settings_cls, json_file=cls.config_default_file_path
)
GeneralSettings._config_folder_path = config_dir
GeneralSettings._config_file_path = config_file
setting_sources.append(default_settings)
return tuple(setting_sources)
return (
init_settings,
env_settings,
dotenv_settings,
file_settings,
default_settings,
)
@classproperty
def config_default_file_path(cls) -> Path:
@@ -376,24 +328,13 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""
if hasattr(self, "_initialized"):
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:
"""Re-initialize global settings."""
# Check for config file content/ version type
config_file, exists = self._get_config_file_path()
if exists:
with config_file.open("r", encoding="utf-8", newline=None) as f_config:
config_txt = f_config.read()
if '"directories": {' in config_txt or '"server_eos_host": ' in config_txt:
error_msg = f"Configuration file '{config_file}' is outdated. Please remove or update manually."
logger.error(error_msg)
raise ValueError(error_msg)
# Assure settings base knows EOS configuration
SettingsBaseModel.config = self
# (Re-)load settings
SettingsEOSDefaults.__init__(self, *args, **kwargs)
# Init config file and data folder pathes
self._create_initial_config_file()
self._update_data_folder_path()
@@ -407,9 +348,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
ValueError: If the `settings` is not a `SettingsEOS` instance.
"""
if not isinstance(settings, SettingsEOS):
error_msg = f"Settings must be an instance of SettingsEOS: '{settings}'."
logger.error(error_msg)
raise ValueError(error_msg)
raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.")
self.merge_settings_from_dict(settings.model_dump(exclude_none=True, exclude_unset=True))
@@ -445,7 +384,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
if self.general.config_file_path and not self.general.config_file_path.exists():
self.general.config_file_path.parent.mkdir(parents=True, exist_ok=True)
try:
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f:
with open(self.general.config_file_path, "w") as f:
f.write(self.model_dump_json(indent=4))
except Exception as e:
logger.error(
@@ -486,10 +425,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
@classmethod
def _get_config_file_path(cls) -> tuple[Path, bool]:
"""Find a valid configuration file or return the desired path for a new config file.
"""Finds the a valid configuration file or returns the desired path for a new config file.
Returns:
tuple[Path, bool]: The path to the configuration file and if there is already a config file there
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there
"""
config_dirs = []
env_base_dir = os.getenv(cls.EOS_DIR)
@@ -498,7 +437,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
logger.debug(f"Environment config dir: '{env_dir}'")
if env_dir is not None:
config_dirs.append(env_dir.resolve())
config_dirs.append(Path(user_config_dir(cls.APP_NAME, cls.APP_AUTHOR)))
config_dirs.append(Path(user_config_dir(cls.APP_NAME)))
config_dirs.append(Path.cwd())
for cdir in config_dirs:
cfile = cdir.joinpath(cls.CONFIG_FILE_NAME)
@@ -517,8 +456,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""
if not self.general.config_file_path:
raise ValueError("Configuration file path unknown.")
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out:
json_str = super().model_dump_json(indent=4)
with self.general.config_file_path.open("w", encoding=self.ENCODING) as f_out:
json_str = super().model_dump_json()
f_out.write(json_str)
def update(self) -> None:

View File

@@ -1,12 +1,9 @@
"""Abstract and base classes for configuration."""
from typing import Any, ClassVar
from akkudoktoreos.core.pydantic import PydanticBaseModel
class SettingsBaseModel(PydanticBaseModel):
"""Base model class for all settings configurations."""
# EOS configuration - set by ConfigEOS
config: ClassVar[Any] = None
pass

View File

@@ -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)

View File

@@ -13,10 +13,13 @@ Classes:
import threading
from typing import Any, ClassVar, Dict, Optional, Type
from loguru import logger
from pendulum import DateTime
from pydantic import computed_field
from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
config_eos: Any = None
measurement_eos: Any = None
prediction_eos: Any = None
@@ -262,12 +265,10 @@ class SingletonMixin:
class MySingletonModel(SingletonMixin, PydanticBaseModel):
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:
if hasattr(self, "_initialized"):
return
# Your initialisation here
...
super().__init__(*args, **kwargs)
instance1 = MySingletonModel(name="Instance 1")

View File

@@ -19,7 +19,6 @@ from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union, over
import numpy as np
import pandas as pd
import pendulum
from loguru import logger
from numpydantic import NDArray, Shape
from pendulum import DateTime, Duration
from pydantic import (
@@ -32,6 +31,7 @@ from pydantic import (
)
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin, StartMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import (
PydanticBaseModel,
PydanticDateTimeData,
@@ -39,6 +39,8 @@ from akkudoktoreos.core.pydantic import (
)
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
logger = get_logger(__name__)
class DataBase(ConfigMixin, StartMixin, PydanticBaseModel):
"""Base class for handling generic data.
@@ -809,8 +811,7 @@ class DataSequence(DataBase, MutableSequence):
dates, values = self.key_to_lists(
key=key, start_datetime=start_datetime, end_datetime=end_datetime, dropna=dropna
)
series = pd.Series(data=values, index=pd.DatetimeIndex(dates), name=key)
return series
return pd.Series(data=values, index=pd.DatetimeIndex(dates), name=key)
def key_from_series(self, key: str, series: pd.Series) -> None:
"""Update the DataSequence from a Pandas Series.
@@ -952,44 +953,6 @@ class DataSequence(DataBase, MutableSequence):
array = resampled.values
return array
def to_dataframe(
self,
start_datetime: Optional[DateTime] = None,
end_datetime: Optional[DateTime] = None,
) -> pd.DataFrame:
"""Converts the sequence of DataRecord instances into a Pandas DataFrame.
Args:
start_datetime (Optional[datetime]): The lower bound for filtering (inclusive).
Defaults to the earliest possible datetime if None.
end_datetime (Optional[datetime]): The upper bound for filtering (exclusive).
Defaults to the latest possible datetime if None.
Returns:
pd.DataFrame: A DataFrame containing the filtered data from all records.
"""
if not self.records:
return pd.DataFrame() # Return empty DataFrame if no records exist
# Use filter_by_datetime to get filtered records
filtered_records = self.filter_by_datetime(start_datetime, end_datetime)
# Convert filtered records to a dictionary list
data = [record.model_dump() for record in filtered_records]
# Convert to DataFrame
df = pd.DataFrame(data)
if df.empty:
return df
# Ensure `date_time` column exists and use it for the index
if not "date_time" in df.columns:
error_msg = f"Cannot create dataframe: no `date_time` column in `{df}`."
logger.error(error_msg)
raise TypeError(error_msg)
df.index = pd.DatetimeIndex(df["date_time"])
return df
def sort_by_datetime(self, reverse: bool = False) -> None:
"""Sort the DataRecords in the sequence by their date_time attribute.
@@ -1266,14 +1229,14 @@ class DataImportMixin:
# We jump back by 1 hour
# Repeat the value(s) (reuse value index)
for i in range(interval_steps_per_hour):
logger.debug(f"{i + 1}: Repeat at {next_time} with index {value_index}")
logger.debug(f"{i+1}: Repeat at {next_time} with index {value_index}")
timestamps_with_indices.append((next_time, value_index))
next_time = next_time.add(seconds=interval.total_seconds())
else:
# We jump forward by 1 hour
# Drop the value(s)
logger.debug(
f"{i + 1}: Skip {interval_steps_per_hour} at {next_time} with index {value_index}"
f"{i+1}: Skip {interval_steps_per_hour} at {next_time} with index {value_index}"
)
value_index += interval_steps_per_hour
@@ -1502,7 +1465,7 @@ class DataImportMixin:
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
logger.debug(f"PydanticDateTimeDataFrame import: {error_msg}")
# Try dictionary with special keys start_datetime and interval
# Try dictionary with special keys start_datetime and intervall
try:
import_data = PydanticDateTimeData.model_validate_json(json_str)
self.import_from_dict(import_data.to_dict())
@@ -1562,7 +1525,7 @@ class DataImportMixin:
and `key_prefix = "load"`, only the "load_mean" key will be processed even though
both keys are in the record.
"""
with import_file_path.open("r", encoding="utf-8", newline=None) as import_file:
with import_file_path.open("r") as import_file:
import_str = import_file.read()
self.import_from_json(
import_str, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval
@@ -1844,88 +1807,6 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
return array
def keys_to_dataframe(
self,
keys: list[str],
start_datetime: Optional[DateTime] = None,
end_datetime: Optional[DateTime] = None,
interval: Optional[Any] = None, # Duration assumed
fill_method: Optional[str] = None,
) -> pd.DataFrame:
"""Retrieve a dataframe indexed by fixed time intervals for specified keys from the data in each DataProvider.
Generates a pandas DataFrame using the NumPy arrays for each specified key, ensuring a common time index..
Args:
keys (list[str]): A list of field names to retrieve.
start_datetime (datetime, optional): Start date for filtering records (inclusive).
end_datetime (datetime, optional): End date for filtering records (exclusive).
interval (duration, optional): The fixed time interval. Defaults to 1 hour.
fill_method (str, optional): Method to handle missing values during resampling.
- 'linear': Linearly interpolate missing values (for numeric data only).
- 'ffill': Forward fill missing values.
- 'bfill': Backward fill missing values.
- 'none': Defaults to 'linear' for numeric values, otherwise 'ffill'.
Returns:
pd.DataFrame: A DataFrame where each column represents a key's array with a common time index.
Raises:
KeyError: If no valid data is found for any of the requested keys.
ValueError: If any retrieved array has a different time index than the first one.
"""
# Ensure datetime objects are normalized
start_datetime = to_datetime(start_datetime, to_maxtime=False) if start_datetime else None
end_datetime = to_datetime(end_datetime, to_maxtime=False) if end_datetime else None
if interval is None:
interval = to_duration("1 hour")
if start_datetime is None:
# Take earliest datetime of all providers that are enabled
for provider in self.enabled_providers:
if start_datetime is None:
start_datetime = provider.min_datetime
elif (
provider.min_datetime
and compare_datetimes(provider.min_datetime, start_datetime).lt
):
start_datetime = provider.min_datetime
if end_datetime is None:
# Take latest datetime of all providers that are enabled
for provider in self.enabled_providers:
if end_datetime is None:
end_datetime = provider.max_datetime
elif (
provider.max_datetime
and compare_datetimes(provider.max_datetime, end_datetime).gt
):
end_datetime = provider.min_datetime
if end_datetime:
end_datetime.add(seconds=1)
# Create a DatetimeIndex based on start, end, and interval
reference_index = pd.date_range(
start=start_datetime, end=end_datetime, freq=interval, inclusive="left"
)
data = {}
for key in keys:
try:
array = self.key_to_array(key, start_datetime, end_datetime, interval, fill_method)
if len(array) != len(reference_index):
raise ValueError(
f"Array length mismatch for key '{key}' (expected {len(reference_index)}, got {len(array)})"
)
data[key] = array
except KeyError as e:
raise KeyError(f"Failed to retrieve data for key '{key}': {e}")
if not data:
raise KeyError(f"No valid data found for the requested keys {keys}.")
return pd.DataFrame(data, index=reference_index)
def provider_by_id(self, provider_id: str) -> DataProvider:
"""Retrieves a data provider by its unique identifier.

View File

@@ -1,6 +1,10 @@
from collections.abc import Callable
from typing import Any, Optional
from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
class classproperty:
"""A decorator to define a read-only property at the class level.
@@ -15,6 +19,7 @@ class classproperty:
class MyClass:
_value = 42
@classmethod
@classproperty
def value(cls):
return cls._value
@@ -30,7 +35,7 @@ class classproperty:
argument and returns a value.
Raises:
RuntimeError: If `fget` is not defined when `__get__` is called.
AssertionError: If `fget` is not defined when `__get__` is called.
"""
def __init__(self, fget: Callable[[Any], Any]) -> None:
@@ -39,6 +44,5 @@ class classproperty:
def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any:
if owner_cls is None:
return self
if self.fget is None:
raise RuntimeError("'fget' not defined when `__get__` is called")
assert self.fget is not None
return self.fget(owner_cls)

View File

@@ -1,24 +1,24 @@
import traceback
from typing import Any, ClassVar, Optional
import numpy as np
from loguru import logger
from numpydantic import NDArray, Shape
from pendulum import DateTime
from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator
from typing_extensions import Self
from akkudoktoreos.core.cache import CacheUntilUpdateStore
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel, PydanticBaseModel
from akkudoktoreos.devices.battery import Battery
from akkudoktoreos.devices.generic import HomeAppliance
from akkudoktoreos.devices.inverter import Inverter
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
class EnergyManagementParameters(ParametersBaseModel):
class EnergieManagementSystemParameters(ParametersBaseModel):
pv_prognose_wh: list[float] = Field(
description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
)
@@ -107,7 +107,7 @@ class SimulationResult(ParametersBaseModel):
return NumpyEncoder.convert_numpy(field)[0]
class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel):
class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel):
# Disable validation on assignment to speed up simulation runs.
model_config = ConfigDict(
validate_assignment=False,
@@ -116,33 +116,16 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
# Start datetime.
_start_datetime: ClassVar[Optional[DateTime]] = None
# last run datetime. Used by energy management task
_last_datetime: ClassVar[Optional[DateTime]] = None
@computed_field # type: ignore[prop-decorator]
@property
def start_datetime(self) -> DateTime:
"""The starting datetime of the current or latest energy management."""
if EnergyManagement._start_datetime is None:
EnergyManagement.set_start_datetime()
return EnergyManagement._start_datetime
if EnergieManagementSystem._start_datetime is None:
EnergieManagementSystem.set_start_datetime()
return EnergieManagementSystem._start_datetime
@classmethod
def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime:
"""Set the start datetime for the next energy management cycle.
If no datetime is provided, the current datetime is used.
The start datetime is always rounded down to the nearest hour
(i.e., setting minutes, seconds, and microseconds to zero).
Args:
start_datetime (Optional[DateTime]): The datetime to set as the start.
If None, the current datetime is used.
Returns:
DateTime: The adjusted start datetime.
"""
if start_datetime is None:
start_datetime = to_datetime()
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
@@ -193,7 +176,7 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
def set_parameters(
self,
parameters: EnergyManagementParameters,
parameters: EnergieManagementSystemParameters,
ev: Optional[Battery] = None,
home_appliance: Optional[HomeAppliance] = None,
inverter: Optional[Inverter] = None,
@@ -260,9 +243,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
is mostly relevant to prediction providers.
force_update (bool, optional): If True, forces to update the data even if still cached.
"""
# Throw away any cached results of the last run.
CacheUntilUpdateStore().clear()
self.set_start_hour(start_hour=start_hour)
self.config.update()
# Check for run definitions
if self.start_datetime is None:
@@ -273,75 +255,14 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
error_msg = "Prediction hours unknown."
logger.error(error_msg)
raise ValueError(error_msg)
if self.config.optimization.hours is None:
error_msg = "Optimization hours unknown."
if self.config.prediction.optimisation_hours is None:
error_msg = "Optimisation hours unknown."
logger.error(error_msg)
raise ValueError(error_msg)
self.prediction.update_data(force_enable=force_enable, force_update=force_update)
# TODO: Create optimisation problem that calls into devices.update_data() for simulations.
logger.info("Energy management run (crippled version - prediction update only)")
def manage_energy(self) -> None:
"""Repeating task for managing energy.
This task should be executed by the server regularly (e.g., every 10 seconds)
to ensure proper energy management. Configuration changes to the energy management interval
will only take effect if this task is executed.
- Initializes and runs the energy management for the first time if it has never been run
before.
- If the energy management interval is not configured or invalid (NaN), the task will not
trigger any repeated energy management runs.
- Compares the current time with the last run time and runs the energy management if the
interval has elapsed.
- Logs any exceptions that occur during the initialization or execution of the energy
management.
Note: The task maintains the interval even if some intervals are missed.
"""
current_datetime = to_datetime()
interval = self.config.ems.interval # interval maybe changed in between
if EnergyManagement._last_datetime is None:
# Never run before
try:
# Remember energy run datetime.
EnergyManagement._last_datetime = current_datetime
# Try to run a first energy management. May fail due to config incomplete.
self.run()
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
message = f"EOS init: {e}\n{trace}"
logger.error(message)
return
if interval is None or interval == float("nan"):
# No Repetition
return
if (
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
< interval
):
# Wait for next run
return
try:
self.run()
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
message = f"EOS run: {e}\n{trace}"
logger.error(message)
# Remember the energy management run - keep on interval even if we missed some intervals
while (
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
>= interval
):
EnergyManagement._last_datetime = EnergyManagement._last_datetime.add(seconds=interval)
def set_start_hour(self, start_hour: Optional[int] = None) -> None:
"""Sets start datetime to given hour.
@@ -394,8 +315,7 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
# Fetch objects
battery = self.battery
if battery is None:
raise ValueError(f"battery not set: {battery}")
assert battery # to please mypy
ev = self.ev
home_appliance = self.home_appliance
inverter = self.inverter
@@ -520,9 +440,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
# Initialize the Energy Management System, it is a singleton.
ems = EnergyManagement()
ems = EnergieManagementSystem()
def get_ems() -> EnergyManagement:
def get_ems() -> EnergieManagementSystem:
"""Gets the EOS Energy Management System."""
return ems

View File

@@ -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"],
)

View File

@@ -1,3 +1,20 @@
"""Abstract and base classes for logging."""
LOGGING_LEVELS: list[str] = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
import logging
def logging_str_to_level(level_str: str) -> int:
"""Convert log level string to logging level."""
if level_str == "DEBUG":
level = logging.DEBUG
elif level_str == "INFO":
level = logging.INFO
elif level_str == "WARNING":
level = logging.WARNING
elif level_str == "CRITICAL":
level = logging.CRITICAL
elif level_str == "ERROR":
level = logging.ERROR
else:
raise ValueError(f"Unknown loggin level: {level_str}")
return level

View File

@@ -1,241 +1,91 @@
"""Utility for configuring Loguru loggers."""
"""Utility functions for handling logging tasks.
Functions:
----------
- get_logger: Creates and configures a logger with console and optional rotating file logging.
Example usage:
--------------
# Logger setup
>>> logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
>>> logger.info("Logging initialized.")
Notes:
------
- The logger supports rotating log files to prevent excessive log file size.
"""
import json
import logging as pylogging
import os
import re
import sys
from pathlib import Path
from types import FrameType
from typing import Any, List, Optional
from logging.handlers import RotatingFileHandler
from typing import Optional
import pendulum
from loguru import logger
from akkudoktoreos.core.logabc import LOGGING_LEVELS
from akkudoktoreos.core.logabc import logging_str_to_level
class InterceptHandler(pylogging.Handler):
"""A logging handler that redirects standard Python logging messages to Loguru.
def get_logger(
name: str,
log_file: Optional[str] = None,
logging_level: Optional[str] = None,
max_bytes: int = 5000000,
backup_count: int = 5,
) -> pylogging.Logger:
"""Creates and configures a logger with a given name.
This handler ensures consistency between the `logging` module and Loguru by intercepting
logs sent to the standard logging system and re-emitting them through Loguru with proper
formatting and context (including exception info and call depth).
Attributes:
loglevel_mapping (dict): Mapping from standard logging levels to Loguru level names.
"""
loglevel_mapping: dict[int, str] = {
50: "CRITICAL",
40: "ERROR",
30: "WARNING",
20: "INFO",
10: "DEBUG",
5: "TRACE",
0: "NOTSET",
}
def emit(self, record: pylogging.LogRecord) -> None:
"""Emits a logging record by forwarding it to Loguru with preserved metadata.
The logger supports logging to both the console and an optional log file. File logging is
handled by a rotating file handler to prevent excessive log file size.
Args:
record (logging.LogRecord): A record object containing log message and metadata.
"""
try:
level = logger.level(record.levelname).name
except AttributeError:
level = self.loglevel_mapping.get(record.levelno, "INFO")
frame: Optional[FrameType] = pylogging.currentframe()
depth: int = 2
while frame and frame.f_code.co_filename == pylogging.__file__:
frame = frame.f_back
depth += 1
log = logger.bind(request_id="app")
log.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
console_handler_id = None
file_handler_id = None
def track_logging_config(config_eos: Any, path: str, old_value: Any, value: Any) -> None:
"""Track logging config changes."""
global console_handler_id, file_handler_id
if not path.startswith("logging"):
raise ValueError(f"Logging shall not track '{path}'")
if not config_eos.logging.console_level:
# No value given - check environment value - may also be None
config_eos.logging.console_level = os.getenv("EOS_LOGGING__LEVEL")
if not config_eos.logging.file_level:
# No value given - check environment value - may also be None
config_eos.logging.file_level = os.getenv("EOS_LOGGING__LEVEL")
# Remove handlers
if console_handler_id:
try:
logger.remove(console_handler_id)
except Exception as e:
logger.debug("Exception on logger.remove: {}", e, exc_info=True)
console_handler_id = None
if file_handler_id:
try:
logger.remove(file_handler_id)
except Exception as e:
logger.debug("Exception on logger.remove: {}", e, exc_info=True)
file_handler_id = None
# Create handlers with new configuration
# Always add console handler
if config_eos.logging.console_level not in LOGGING_LEVELS:
logger.error(
f"Invalid console log level '{config_eos.logging.console_level} - forced to INFO'."
)
config_eos.logging.console_level = "INFO"
console_handler_id = logger.add(
sys.stderr,
enqueue=True,
backtrace=True,
level=config_eos.logging.console_level,
# format=_console_format
)
# Add file handler
if config_eos.logging.file_level and config_eos.logging.file_path:
if config_eos.logging.file_level not in LOGGING_LEVELS:
logger.error(
f"Invalid file log level '{config_eos.logging.console_level}' - forced to INFO."
)
config_eos.logging.file_level = "INFO"
file_handler_id = logger.add(
sink=config_eos.logging.file_path,
rotation="100 MB",
retention="3 days",
enqueue=True,
backtrace=True,
level=config_eos.logging.file_level,
serialize=True, # JSON dict formatting
# format=_file_format
)
# Redirect standard logging to Loguru
pylogging.basicConfig(handlers=[InterceptHandler()], level=0)
# Redirect uvicorn and fastapi logging to Loguru
pylogging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
for pylogger_name in ["uvicorn", "uvicorn.error", "fastapi"]:
pylogger = pylogging.getLogger(pylogger_name)
pylogger.handlers = [InterceptHandler()]
pylogger.propagate = False
logger.info(
f"Logger reconfigured - console: {config_eos.logging.console_level}, file: {config_eos.logging.file_level}."
)
def read_file_log(
log_path: Path,
limit: int = 100,
level: Optional[str] = None,
contains: Optional[str] = None,
regex: Optional[str] = None,
from_time: Optional[str] = None,
to_time: Optional[str] = None,
tail: bool = False,
) -> List[dict]:
"""Read and filter structured log entries from a JSON-formatted log file.
Args:
log_path (Path): Path to the JSON-formatted log file.
limit (int, optional): Maximum number of log entries to return. Defaults to 100.
level (Optional[str], optional): Filter logs by log level (e.g., "INFO", "ERROR"). Defaults to None.
contains (Optional[str], optional): Filter logs that contain this substring in their message. Case-insensitive. Defaults to None.
regex (Optional[str], optional): Filter logs whose message matches this regular expression. Defaults to None.
from_time (Optional[str], optional): ISO 8601 datetime string to filter logs not earlier than this time. Defaults to None.
to_time (Optional[str], optional): ISO 8601 datetime string to filter logs not later than this time. Defaults to None.
tail (bool, optional): If True, read the last lines of the file (like `tail -n`). Defaults to False.
name (str): The name of the logger, typically `__name__` from the calling module.
log_file (Optional[str]): Path to the log file for file logging. If None, no file logging is done.
logging_level (Optional[str]): Logging level (e.g., "INFO", "DEBUG"). Defaults to "INFO".
max_bytes (int): Maximum size in bytes for log file before rotation. Defaults to 5 MB.
backup_count (int): Number of backup log files to keep. Defaults to 5.
Returns:
List[dict]: A list of filtered log entries as dictionaries.
logging.Logger: Configured logger instance.
Raises:
FileNotFoundError: If the log file does not exist.
ValueError: If the datetime strings are invalid or improperly formatted.
Exception: For other unforeseen I/O or parsing errors.
Example:
logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
logger.info("Application started")
"""
if not log_path.exists():
raise FileNotFoundError("Log file not found")
# Create a logger with the specified name
logger = pylogging.getLogger(name)
logger.propagate = True
if logging_level is not None:
level = logging_str_to_level(logging_level)
logger.setLevel(level)
try:
from_dt = pendulum.parse(from_time) if from_time else None
to_dt = pendulum.parse(to_time) if to_time else None
except Exception as e:
raise ValueError(f"Invalid date/time format: {e}")
# The log message format
formatter = pylogging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
regex_pattern = re.compile(regex) if regex else None
# Prevent loggers from being added multiple times
# There may already be a logger from pytest
if not logger.handlers:
# Create a console handler with a standard output stream
console_handler = pylogging.StreamHandler()
if logging_level is not None:
console_handler.setLevel(level)
console_handler.setFormatter(formatter)
def matches_filters(log: dict) -> bool:
if level and log.get("level", {}).get("name") != level.upper():
return False
if contains and contains.lower() not in log.get("message", "").lower():
return False
if regex_pattern and not regex_pattern.search(log.get("message", "")):
return False
if from_dt or to_dt:
try:
log_time = pendulum.parse(log["time"])
except Exception:
return False
if from_dt and log_time < from_dt:
return False
if to_dt and log_time > to_dt:
return False
return True
# Add the console handler to the logger
logger.addHandler(console_handler)
matched_logs = []
lines: list[str] = []
if log_file and len(logger.handlers) < 2: # We assume a console logger to be the first logger
# If a log file path is specified, create a rotating file handler
if tail:
with log_path.open("rb") as f:
f.seek(0, 2)
end = f.tell()
buffer = bytearray()
pointer = end
# Ensure the log directory exists
log_dir = os.path.dirname(log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir)
while pointer > 0 and len(lines) < limit * 5:
pointer -= 1
f.seek(pointer)
byte = f.read(1)
if byte == b"\n":
if buffer:
line = buffer[::-1].decode("utf-8", errors="ignore")
lines.append(line)
buffer.clear()
else:
buffer.append(byte[0])
if buffer:
line = buffer[::-1].decode("utf-8", errors="ignore")
lines.append(line)
lines = lines[::-1]
else:
with log_path.open("r", encoding="utf-8", newline=None) as f_txt:
lines = f_txt.readlines()
# Create a rotating file handler
file_handler = RotatingFileHandler(log_file, maxBytes=max_bytes, backupCount=backup_count)
if logging_level is not None:
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
for line in lines:
if not line.strip():
continue
try:
log = json.loads(line)
except json.JSONDecodeError:
continue
if matches_filters(log):
matched_logs.append(log)
if len(matched_logs) >= limit:
break
# Add the file handler to the logger
logger.addHandler(file_handler)
return matched_logs
return logger

View File

@@ -3,13 +3,13 @@
Kept in an extra module to avoid cyclic dependencies on package import.
"""
from pathlib import Path
import logging
from typing import Optional
from pydantic import Field, computed_field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logabc import LOGGING_LEVELS
from akkudoktoreos.core.logabc import logging_str_to_level
class LoggingCommonSettings(SettingsBaseModel):
@@ -17,47 +17,27 @@ class LoggingCommonSettings(SettingsBaseModel):
level: Optional[str] = Field(
default=None,
deprecated="This is deprecated. Use console_level and file_level instead.",
description="EOS default logging level.",
examples=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"],
)
console_level: Optional[str] = Field(
default=None,
description="Logging level when logging to console.",
examples=LOGGING_LEVELS,
)
file_level: Optional[str] = Field(
default=None,
description="Logging level when logging to file.",
examples=LOGGING_LEVELS,
)
@computed_field # type: ignore[prop-decorator]
@property
def file_path(self) -> Optional[Path]:
"""Computed log file path based on data output path."""
try:
path = SettingsBaseModel.config.general.data_output_path / "eos.log"
except:
# Config may not be fully set up
path = None
return path
# Validators
@field_validator("console_level", "file_level", mode="after")
@field_validator("level", mode="after")
@classmethod
def validate_level(cls, value: Optional[str]) -> Optional[str]:
"""Validate logging level string."""
def set_default_logging_level(cls, value: Optional[str]) -> Optional[str]:
if isinstance(value, str) and value.upper() == "NONE":
value = None
if value is None:
# Nothing to set
return None
if isinstance(value, str):
level = value.upper()
if level == "NONE":
return None
if level not in LOGGING_LEVELS:
raise ValueError(f"Logging level {value} not supported")
value = level
else:
raise TypeError(f"Invalid {type(value)} of logging level {value}")
level = logging_str_to_level(value)
logging.getLogger().setLevel(level)
return value
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def root_level(self) -> str:
"""Root logger logging level."""
level = logging.getLogger().getEffectiveLevel()
level_name = logging.getLevelName(level)
return level_name

View File

@@ -12,35 +12,20 @@ Key Features:
pandas DataFrames and Series with datetime indexes.
"""
import inspect
import json
import re
import uuid
import weakref
from copy import deepcopy
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Type,
Union,
get_args,
get_origin,
)
from typing import Any, Dict, List, Optional, Type, Union
from zoneinfo import ZoneInfo
import pandas as pd
import pendulum
from loguru import logger
from pandas.api.types import is_datetime64_any_dtype
from pydantic import (
AwareDatetime,
BaseModel,
ConfigDict,
Field,
PrivateAttr,
RootModel,
TypeAdapter,
ValidationError,
@@ -50,10 +35,6 @@ from pydantic import (
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
# Global weakref dictionary to hold external state per model instance
# Used as a workaround for PrivateAttr not working in e.g. Mixin Classes
_model_private_state: "weakref.WeakKeyDictionary[Union[PydanticBaseModel, PydanticModelNestedValueMixin], Dict[str, Any]]" = weakref.WeakKeyDictionary()
def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]:
def deep_update(source_dict: dict[str, Any], update_dict: dict[str, Any]) -> dict[str, Any]:
@@ -101,538 +82,11 @@ class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
return bool(re.match(iso8601_pattern, value))
class PydanticModelNestedValueMixin:
"""A mixin providing methods to get, set and track nested values within a Pydantic model.
class PydanticBaseModel(BaseModel):
"""Base model class with automatic serialization and deserialization of `pendulum.DateTime` fields.
The methods use a '/'-separated path to denote the nested values.
Supports handling `Optional`, `List`, and `Dict` types, ensuring correct initialization of
missing attributes.
Example:
class Address(PydanticBaseModel):
city: str
class User(PydanticBaseModel):
name: str
address: Address
def on_city_change(old, new, path):
print(f"{path}: {old} -> {new}")
user = User(name="Alice", address=Address(city="NY"))
user.track_nested_value("address/city", on_city_change)
user.set_nested_value("address/city", "LA") # triggers callback
"""
def track_nested_value(self, path: str, callback: Callable[[Any, str, Any, Any], None]) -> None:
"""Register a callback for a specific path (or subtree).
Callback triggers if set path is equal or deeper.
Args:
path (str): '/'-separated path to track.
callback (callable): Function called as callback(model_instance, set_path, old_value, new_value).
"""
try:
self._validate_path_structure(path)
pass
except:
raise ValueError(f"Path '{path}' is invalid")
path = path.strip("/")
# Use private data workaround
# Should be:
# _nested_value_callbacks: dict[str, list[Callable[[str, Any, Any], None]]]
# = PrivateAttr(default_factory=dict)
nested_value_callbacks = get_private_attr(self, "nested_value_callbacks", dict())
if path not in nested_value_callbacks:
nested_value_callbacks[path] = []
nested_value_callbacks[path].append(callback)
set_private_attr(self, "nested_value_callbacks", nested_value_callbacks)
logger.debug("Nested value callbacks {}", nested_value_callbacks)
def _validate_path_structure(self, path: str) -> None:
"""Validate that a '/'-separated path is structurally valid for this model.
Checks that each segment of the path corresponds to a field or index in the model's type structure,
without requiring that all intermediate values are currently initialized. This method is intended
to ensure that the path could be valid for nested access or assignment, according to the model's
class definition.
Args:
path (str): The '/'-separated attribute/index path to validate (e.g., "address/city" or "items/0/value").
Raises:
ValueError: If any segment of the path does not correspond to a valid field in the model,
or an invalid transition is made (such as an attribute on a non-model).
Example:
class Address(PydanticBaseModel):
city: str
class User(PydanticBaseModel):
name: str
address: Address
user = User(name="Alice", address=Address(city="NY"))
user._validate_path_structure("address/city") # OK
user._validate_path_structure("address/zipcode") # Raises ValueError
"""
path_elements = path.strip("/").split("/")
# The model we are currently working on
model: Any = self
# The model we get the type information from. It is a pydantic BaseModel
parent: BaseModel = model
# The field that provides type information for the current key
# Fields may have nested types that translates to a sequence of keys, not just one
# - my_field: Optional[list[OtherModel]] -> e.g. "myfield/0" for index 0
# parent_key = ["myfield",] ... ["myfield", "0"]
# parent_key_types = [list, OtherModel]
parent_key: list[str] = []
parent_key_types: list = []
for i, key in enumerate(path_elements):
is_final_key = i == len(path_elements) - 1
# Add current key to parent key to enable nested type tracking
parent_key.append(key)
# Get next value
next_value = None
if isinstance(model, BaseModel):
# Track parent and key for possible assignment later
parent = model
parent_key = [
key,
]
parent_key_types = self._get_key_types(model.__class__, key)
# If this is the final key, set the value
if is_final_key:
return
# Attempt to access the next attribute, handling None values
next_value = getattr(model, key, None)
# Handle missing values (initialize dict/list/model if necessary)
if next_value is None:
next_type = parent_key_types[len(parent_key) - 1]
next_value = self._initialize_value(next_type)
elif isinstance(model, list):
# Handle lists
try:
idx = int(key)
except Exception as e:
raise IndexError(
f"Invalid list index '{key}' at '{path}': key = '{key}'; parent = '{parent}', parent_key = '{parent_key}'; model = '{model}'; {e}"
)
# Get next type from parent key type information
next_type = parent_key_types[len(parent_key) - 1]
if len(model) > idx:
next_value = model[idx]
else:
return
if is_final_key:
return
elif isinstance(model, dict):
# Handle dictionaries (auto-create missing keys)
# Get next type from parent key type information
next_type = parent_key_types[len(parent_key) - 1]
if is_final_key:
return
if key not in model:
return
else:
next_value = model[key]
else:
raise KeyError(f"Key '{key}' not found in model.")
# Move deeper
model = next_value
def get_nested_value(self, path: str) -> Any:
"""Retrieve a nested value from the model using a '/'-separated path.
Supports accessing nested attributes and list indices.
Args:
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
Returns:
Any: The retrieved value.
Raises:
KeyError: If a key is not found in the model.
IndexError: If a list index is out of bounds or invalid.
Example:
```python
class Address(PydanticBaseModel):
city: str
class User(PydanticBaseModel):
name: str
address: Address
user = User(name="Alice", address=Address(city="New York"))
city = user.get_nested_value("address/city")
print(city) # Output: "New York"
```
"""
path_elements = path.strip("/").split("/")
model: Any = self
for key in path_elements:
if isinstance(model, list):
try:
model = model[int(key)]
except (ValueError, IndexError) as e:
raise IndexError(f"Invalid list index at '{path}': {key}; {e}")
elif isinstance(model, dict):
try:
model = model[key]
except Exception as e:
raise KeyError(f"Invalid dict key at '{path}': {key}; {e}")
elif isinstance(model, BaseModel):
model = getattr(model, key)
else:
raise KeyError(f"Key '{key}' not found in model.")
return model
def set_nested_value(self, path: str, value: Any) -> None:
"""Set a nested value in the model using a '/'-separated path.
Supports modifying nested attributes and list indices while preserving Pydantic validation.
Automatically initializes missing `Optional`, `Union`, `dict`, and `list` fields if necessary.
If a missing field cannot be initialized, raises an exception.
Triggers the callbacks registered by track_nested_value().
Args:
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
value (Any): The new value to set.
Raises:
KeyError: If a key is not found in the model.
IndexError: If a list index is out of bounds or invalid.
ValueError: If a validation error occurs.
TypeError: If a missing field cannot be initialized.
Example:
```python
class Address(PydanticBaseModel):
city: Optional[str]
class User(PydanticBaseModel):
name: str
address: Optional[Address]
settings: Optional[Dict[str, Any]]
user = User(name="Alice", address=None, settings=None)
user.set_nested_value("address/city", "Los Angeles")
user.set_nested_value("settings/theme", "dark")
print(user.address.city) # Output: "Los Angeles"
print(user.settings) # Output: {'theme': 'dark'}
```
"""
path = path.strip("/")
# Store old value (if possible)
try:
old_value = self.get_nested_value(path)
except Exception as e:
# We can not get the old value
# raise ValueError(f"Can not get old (current) value of '{path}': {e}") from e
old_value = None
# Proceed with core logic
self._set_nested_value(path, value)
# Trigger all callbacks whose path is a prefix of set path
triggered = set()
nested_value_callbacks = get_private_attr(self, "nested_value_callbacks", dict())
for cb_path, callbacks in nested_value_callbacks.items():
# Match: cb_path == path, or cb_path is a prefix (parent) of path
pass
if path == cb_path or path.startswith(cb_path + "/"):
for cb in callbacks:
# Prevent duplicate calls
if (cb_path, id(cb)) not in triggered:
cb(self, path, old_value, value)
triggered.add((cb_path, id(cb)))
def _set_nested_value(self, path: str, value: Any) -> None:
"""Set a nested value core logic.
Args:
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
value (Any): The new value to set.
Raises:
KeyError: If a key is not found in the model.
IndexError: If a list index is out of bounds or invalid.
ValueError: If a validation error occurs.
TypeError: If a missing field cannot be initialized.
"""
path_elements = path.strip("/").split("/")
# The model we are currently working on
model: Any = self
# The model we get the type information from. It is a pydantic BaseModel
parent: BaseModel = model
# The field that provides type information for the current key
# Fields may have nested types that translates to a sequence of keys, not just one
# - my_field: Optional[list[OtherModel]] -> e.g. "myfield/0" for index 0
# parent_key = ["myfield",] ... ["myfield", "0"]
# parent_key_types = [list, OtherModel]
parent_key: list[str] = []
parent_key_types: list = []
for i, key in enumerate(path_elements):
is_final_key = i == len(path_elements) - 1
# Add current key to parent key to enable nested type tracking
parent_key.append(key)
# Get next value
next_value = None
if isinstance(model, BaseModel):
# Track parent and key for possible assignment later
parent = model
parent_key = [
key,
]
parent_key_types = self._get_key_types(model.__class__, key)
# If this is the final key, set the value
if is_final_key:
try:
model.__pydantic_validator__.validate_assignment(model, key, value)
except ValidationError as e:
raise ValueError(f"Error updating model: {e}") from e
return
# Attempt to access the next attribute, handling None values
next_value = getattr(model, key, None)
# Handle missing values (initialize dict/list/model if necessary)
if next_value is None:
next_type = parent_key_types[len(parent_key) - 1]
next_value = self._initialize_value(next_type)
if next_value is None:
raise TypeError(
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
)
setattr(parent, key, next_value)
# pydantic may copy on validation assignment - reread to get the copied model
next_value = getattr(model, key, None)
elif isinstance(model, list):
# Handle lists (ensure index exists and modify safely)
try:
idx = int(key)
except Exception as e:
raise IndexError(
f"Invalid list index '{key}' at '{path}': key = '{key}'; parent = '{parent}', parent_key = '{parent_key}'; model = '{model}'; {e}"
)
# Get next type from parent key type information
next_type = parent_key_types[len(parent_key) - 1]
if len(model) > idx:
next_value = model[idx]
else:
# Extend the list with default values if index is out of range
while len(model) <= idx:
next_value = self._initialize_value(next_type)
if next_value is None:
raise TypeError(
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
)
model.append(next_value)
if is_final_key:
if (
(isinstance(next_type, type) and not isinstance(value, next_type))
or (next_type is dict and not isinstance(value, dict))
or (next_type is list and not isinstance(value, list))
):
raise TypeError(
f"Expected type {next_type} for key '{key}' in path '{path}', but got {type(value)}: {value}"
)
model[idx] = value
return
elif isinstance(model, dict):
# Handle dictionaries (auto-create missing keys)
# Get next type from parent key type information
next_type = parent_key_types[len(parent_key) - 1]
if is_final_key:
if (
(isinstance(next_type, type) and not isinstance(value, next_type))
or (next_type is dict and not isinstance(value, dict))
or (next_type is list and not isinstance(value, list))
):
raise TypeError(
f"Expected type {next_type} for key '{key}' in path '{path}', but got {type(value)}: {value}"
)
model[key] = value
return
if key not in model:
next_value = self._initialize_value(next_type)
if next_value is None:
raise TypeError(
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
)
model[key] = next_value
else:
next_value = model[key]
else:
raise KeyError(f"Key '{key}' not found in model.")
# Move deeper
model = next_value
@staticmethod
def _get_key_types(model: Type[BaseModel], key: str) -> List[Union[Type[Any], list, dict]]:
"""Returns a list of nested types for a given Pydantic model key.
- Skips `Optional` and `Union`, using only the first non-None type.
- Skips dictionary keys and only adds value types.
- Keeps `list` and `dict` as origins.
Args:
model (Type[BaseModel]): The Pydantic model class to inspect.
key (str): The attribute name in the model.
Returns:
List[Union[Type[Any], list, dict]]: A list of extracted types, preserving `list` and `dict` origins.
Raises:
TypeError: If the key does not exist or lacks a valid type annotation.
"""
if not inspect.isclass(model):
raise TypeError(f"Model '{model}' is not of class type.")
if key not in model.model_fields:
raise TypeError(f"Field '{key}' does not exist in model '{model.__name__}'.")
field_annotation = model.model_fields[key].annotation
if not field_annotation:
raise TypeError(
f"Missing type annotation for field '{key}' in model '{model.__name__}'."
)
nested_types: list[Union[Type[Any], list, dict]] = []
queue: list[Any] = [field_annotation]
while queue:
annotation = queue.pop(0)
origin = get_origin(annotation)
args = get_args(annotation)
# Handle Union (Optional[X] is treated as Union[X, None])
if origin is Union:
queue.extend(arg for arg in args if arg is not type(None))
continue
# Handle lists and dictionaries
if origin is list:
nested_types.append(list)
if args:
queue.append(args[0]) # Extract value type for list[T]
continue
if origin is dict:
nested_types.append(dict)
if len(args) == 2:
queue.append(args[1]) # Extract only the value type for dict[K, V]
continue
# If it's a BaseModel, add it to the list
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
nested_types.append(annotation)
continue
# Otherwise, it's a standard type (e.g., str, int, bool, float, etc.)
nested_types.append(annotation)
return nested_types
@staticmethod
def _initialize_value(type_hint: Type[Any] | None | list[Any] | dict[Any, Any]) -> Any:
"""Initialize a missing value based on the provided type hint.
Args:
type_hint (Type[Any] | None | list[Any] | dict[Any, Any]): The type hint that determines
how the missing value should be initialized.
Returns:
Any: An instance of the expected type (e.g., list, dict, or Pydantic model), or `None`
if initialization is not possible.
Raises:
TypeError: If instantiation fails.
Example:
- For `list[str]`, returns `[]`
- For `dict[str, Any]`, returns `{}`
- For `Address` (a Pydantic model), returns a new `Address()` instance.
"""
if type_hint is None:
return None
# Handle direct instances of list or dict
if isinstance(type_hint, list):
return []
if isinstance(type_hint, dict):
return {}
origin = get_origin(type_hint)
# Handle generic list and dictionary
if origin is list:
return []
if origin is dict:
return {}
# Handle Pydantic models
if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
try:
return type_hint.model_construct()
except Exception as e:
raise TypeError(f"Failed to initialize model '{type_hint.__name__}': {e}")
# Handle standard built-in types (int, float, str, bool, etc.)
if isinstance(type_hint, type):
try:
return type_hint()
except Exception as e:
raise TypeError(f"Failed to initialize instance of '{type_hint.__name__}': {e}")
raise TypeError(f"Unsupported type hint '{type_hint}' for initialization.")
class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
"""Base model with pendulum datetime support, nested value utilities, and stable hashing.
This class provides:
- ISO 8601 serialization/deserialization of `pendulum.DateTime` fields.
- Nested attribute access and mutation via `PydanticModelNestedValueMixin`.
- A consistent hash using a UUID for use in sets and as dictionary keys
This model serializes pendulum.DateTime objects to ISO 8601 strings and
deserializes ISO 8601 strings to pendulum.DateTime objects.
"""
# Enable custom serialization globally in config
@@ -642,17 +96,6 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
validate_assignment=True,
)
_uuid: str = PrivateAttr(default_factory=lambda: str(uuid.uuid4()))
"""str: A private UUID string generated on instantiation, used for hashing."""
def __hash__(self) -> int:
"""Returns a stable hash based on the instance's UUID.
Returns:
int: Hash value derived from the model's UUID.
"""
return hash(self._uuid)
@field_validator("*", mode="before")
def validate_and_convert_pendulum(cls, value: Any, info: ValidationInfo) -> Any:
"""Validator to convert fields of type `pendulum.DateTime`.
@@ -681,8 +124,8 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
if expected_type is pendulum.DateTime or expected_type is AwareDatetime:
try:
value = to_datetime(value)
except Exception as e:
raise ValueError(f"Cannot convert {value!r} to datetime: {e}")
except:
pass
return value
# Override Pydantics serialization for all DateTime fields
@@ -930,10 +373,6 @@ class PydanticDateTimeDataFrame(PydanticBaseModel):
index = pd.Index([to_datetime(dt, in_timezone=self.tz) for dt in df.index])
df.index = index
# Check if 'date_time' column exists, if not, create it
if "date_time" not in df.columns:
df["date_time"] = df.index
dtype_mapping = {
"int": int,
"float": float,
@@ -1070,27 +509,3 @@ class PydanticDateTimeSeries(PydanticBaseModel):
class ParametersBaseModel(PydanticBaseModel):
model_config = ConfigDict(extra="forbid")
def set_private_attr(
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str, value: Any
) -> None:
"""Set a private attribute for a model instance (not stored in model itself)."""
if model not in _model_private_state:
_model_private_state[model] = {}
_model_private_state[model][key] = value
def get_private_attr(
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str, default: Any = None
) -> Any:
"""Get a private attribute or return default."""
return _model_private_state.get(model, {}).get(key, default)
def del_private_attr(
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str
) -> None:
"""Delete a private attribute."""
if model in _model_private_state and key in _model_private_state[model]:
del _model_private_state[model][key]

View File

@@ -3,6 +3,7 @@ from typing import Any, Optional
import numpy as np
from pydantic import Field, field_validator
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.devices.devicesabc import (
DeviceBase,
DeviceOptimizeResult,
@@ -10,6 +11,8 @@ from akkudoktoreos.devices.devicesabc import (
)
from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None:
@@ -118,8 +121,7 @@ class Battery(DeviceBase):
def _setup(self) -> None:
"""Sets up the battery parameters based on configuration or provided parameters."""
if self.parameters is None:
raise ValueError(f"Parameters not set: {self.parameters}")
assert self.parameters is not None
self.capacity_wh = self.parameters.capacity_wh
self.initial_soc_percentage = self.parameters.initial_soc_percentage
self.charging_efficiency = self.parameters.charging_efficiency

View File

@@ -1,12 +1,15 @@
from typing import Optional
from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.devices.battery import Battery
from akkudoktoreos.devices.devicesabc import DevicesBase
from akkudoktoreos.devices.generic import HomeAppliance
from akkudoktoreos.devices.inverter import Inverter
from akkudoktoreos.devices.settings import DevicesCommonSettings
logger = get_logger(__name__)
class Devices(SingletonMixin, DevicesBase):
def __init__(self, settings: Optional[DevicesCommonSettings] = None):
@@ -37,13 +40,9 @@ class Devices(SingletonMixin, DevicesBase):
# Initialize the Devices simulation, it is a singleton.
devices: Optional[Devices] = None
devices = Devices()
def get_devices() -> Devices:
global devices
# Fix circular import at runtime
if devices is None:
devices = Devices()
"""Gets the EOS Devices simulation."""
return devices

View File

@@ -3,7 +3,6 @@
from enum import Enum
from typing import Optional, Type
from loguru import logger
from pendulum import DateTime
from pydantic import Field, computed_field
@@ -13,9 +12,12 @@ from akkudoktoreos.core.coreabc import (
EnergyManagementSystemMixin,
PredictionMixin,
)
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__)
class DeviceParameters(ParametersBaseModel):
device_id: str = Field(description="ID of device", examples="device1")
@@ -168,8 +170,7 @@ class DevicesBase(DevicesStartEndMixin, PredictionMixin):
def add_device(self, device: Optional["DeviceBase"]) -> None:
if device is None:
return
if device.device_id in self.devices:
raise ValueError(f"{device.device_id} already registered")
assert device.device_id not in self.devices, f"{device.device_id} already registered"
self.devices[device.device_id] = device
def remove_device(self, device: Type["DeviceBase"] | str) -> bool:

View File

@@ -3,8 +3,11 @@ from typing import Optional
import numpy as np
from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
logger = get_logger(__name__)
class HomeApplianceParameters(DeviceParameters):
"""Home Appliance Device Simulation Configuration."""
@@ -31,8 +34,7 @@ class HomeAppliance(DeviceBase):
super().__init__(parameters)
def _setup(self) -> None:
if self.parameters is None:
raise ValueError(f"Parameters not set: {self.parameters}")
assert self.parameters is not None
self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros
self.duration_h = self.parameters.duration_h
self.consumption_wh = self.parameters.consumption_wh

View File

@@ -1,7 +1,6 @@
import logging
from typing import List, Sequence
from loguru import logger
class Heatpump:
MAX_HEAT_OUTPUT = 5000
@@ -22,6 +21,7 @@ class Heatpump:
def __init__(self, max_heat_output: int, hours: int):
self.max_heat_output = max_heat_output
self.hours = hours
self.log = logging.getLogger(__name__)
def __check_outside_temperature_range__(self, temp_celsius: float) -> bool:
"""Check if temperature is in valid range between -100 and 100 degree Celsius.
@@ -58,7 +58,7 @@ class Heatpump:
f"Outside temperature '{outside_temperature_celsius}' not in range "
"(min: -100 Celsius, max: 100 Celsius)"
)
logger.error(err_msg)
self.log.error(err_msg)
raise ValueError(err_msg)
def calculate_heating_output(self, outside_temperature_celsius: float) -> float:
@@ -86,7 +86,7 @@ class Heatpump:
f"Outside temperature '{outside_temperature_celsius}' not in range "
"(min: -100 Celsius, max: 100 Celsius)"
)
logger.error(err_msg)
self.log.error(err_msg)
raise ValueError(err_msg)
def calculate_heat_power(self, outside_temperature_celsius: float) -> float:
@@ -110,7 +110,7 @@ class Heatpump:
f"Outside temperature '{outside_temperature_celsius}' not in range "
"(min: -100 Celsius, max: 100 Celsius)"
)
logger.error(err_msg)
self.log.error(err_msg)
raise ValueError(err_msg)
def simulate_24h(self, temperatures: Sequence[float]) -> List[float]:

View File

@@ -1,11 +1,13 @@
from typing import Optional
from loguru import logger
from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
logger = get_logger(__name__)
class InverterParameters(DeviceParameters):
"""Inverter Device Simulation Configuration."""
@@ -25,25 +27,8 @@ class Inverter(DeviceBase):
self.parameters: Optional[InverterParameters] = None
super().__init__(parameters)
self.scr_lookup: dict = {}
def _calculate_scr(self, consumption: float, generation: float) -> float:
"""Check if the consumption and production is in the lookup table. If not, calculate and store the value."""
if consumption not in self.scr_lookup:
self.scr_lookup[consumption] = {}
if generation not in self.scr_lookup[consumption]:
scr = self.self_consumption_predictor.calculate_self_consumption(
consumption, generation
)
self.scr_lookup[consumption][generation] = scr
return scr
return self.scr_lookup[consumption][generation]
def _setup(self) -> None:
if self.parameters is None:
raise ValueError(f"Parameters not set: {self.parameters}")
assert self.parameters is not None
if self.parameters.battery_id is None:
# For the moment raise exception
# TODO: Make battery configurable by config
@@ -56,8 +41,7 @@ class Inverter(DeviceBase):
) # Maximum power that the inverter can handle
def _post_setup(self) -> None:
if self.parameters is None:
raise ValueError(f"Parameters not set: {self.parameters}")
assert self.parameters is not None
self.battery = self.devices.get_device_by_id(self.parameters.battery_id)
def process_energy(
@@ -76,8 +60,9 @@ class Inverter(DeviceBase):
grid_import = -remaining_power # Negative indicates feeding into the grid
self_consumption = self.max_power_wh
else:
# Calculate scr with lookup table
scr = self._calculate_scr(consumption, generation)
scr = self.self_consumption_predictor.calculate_self_consumption(
consumption, generation
)
# Remaining power after consumption
remaining_power = (generation - consumption) * scr # EVQ

View File

@@ -3,10 +3,13 @@ from typing import Optional
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.devices.battery import BaseBatteryParameters
from akkudoktoreos.devices.generic import HomeApplianceParameters
from akkudoktoreos.devices.inverter import InverterParameters
logger = get_logger(__name__)
class DevicesCommonSettings(SettingsBaseModel):
"""Base configuration for devices simulation settings."""

View File

@@ -9,7 +9,6 @@ The measurements can be added programmatically or imported from a file or JSON s
from typing import Any, ClassVar, List, Optional
import numpy as np
from loguru import logger
from numpydantic import NDArray, Shape
from pendulum import DateTime, Duration
from pydantic import Field, computed_field
@@ -17,8 +16,11 @@ from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.dataabc import DataImportMixin, DataRecord, DataSequence
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__)
class MeasurementCommonSettings(SettingsBaseModel):
"""Measurement Configuration."""

View File

@@ -1,10 +1,10 @@
import logging
import random
import time
from typing import Any, Optional
import numpy as np
from deap import algorithms, base, creator, tools
from loguru import logger
from pydantic import Field, field_validator, model_validator
from typing_extensions import Self
@@ -13,7 +13,8 @@ from akkudoktoreos.core.coreabc import (
DevicesMixin,
EnergyManagementSystemMixin,
)
from akkudoktoreos.core.ems import EnergyManagementParameters, SimulationResult
from akkudoktoreos.core.ems import EnergieManagementSystemParameters, SimulationResult
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import ParametersBaseModel
from akkudoktoreos.devices.battery import (
Battery,
@@ -25,9 +26,11 @@ from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
class OptimizationParameters(ParametersBaseModel):
ems: EnergyManagementParameters
ems: EnergieManagementSystemParameters
pv_akku: Optional[SolarPanelBatteryParameters]
inverter: Optional[InverterParameters]
eauto: Optional[ElectricVehicleParameters]
@@ -118,8 +121,8 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
# Set a fixed seed for random operations if provided or in debug mode
if self.fix_seed is not None:
random.seed(self.fix_seed)
elif logger.level == "DEBUG":
self.fix_seed = random.randint(1, 100000000000) # noqa: S311
elif logger.level == logging.DEBUG:
self.fix_seed = random.randint(1, 100000000000)
random.seed(self.fix_seed)
def decode_charge_discharge(

View File

@@ -3,6 +3,9 @@ from typing import List, Optional
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
class OptimizationCommonSettings(SettingsBaseModel):

View File

@@ -3,8 +3,11 @@
from pydantic import ConfigDict
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
logger = get_logger(__name__)
class OptimizationBase(ConfigMixin, PredictionMixin, PydanticBaseModel):
"""Base class for handling optimization data.

View File

@@ -1,20 +1,9 @@
from typing import Optional
from pydantic import Field, field_validator
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
from akkudoktoreos.prediction.prediction import get_prediction
prediction_eos = get_prediction()
# Valid elecprice providers
elecprice_providers = [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, ElecPriceProvider)
]
class ElecPriceCommonSettings(SettingsBaseModel):
@@ -28,23 +17,7 @@ class ElecPriceCommonSettings(SettingsBaseModel):
charges_kwh: Optional[float] = Field(
default=None, ge=0, description="Electricity price charges (€/kWh).", examples=[0.21]
)
vat_rate: Optional[float] = Field(
default=1.19,
ge=0,
description="VAT rate factor applied to electricity price when charges are used.",
examples=[1.19],
)
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
)
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in elecprice_providers:
return value
raise ValueError(
f"Provider '{value}' is not a valid electricity price provider: {elecprice_providers}."
)

View File

@@ -9,8 +9,11 @@ from typing import List, Optional
from pydantic import Field, computed_field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
logger = get_logger(__name__)
class ElecPriceDataRecord(PredictionRecord):
"""Represents a electricity price data record containing various price attributes at a specific datetime.

View File

@@ -11,15 +11,17 @@ from typing import Any, List, Optional, Union
import numpy as np
import pandas as pd
import requests
from loguru import logger
from pydantic import ValidationError
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.utils.cacheutil import cache_in_file
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
logger = get_logger(__name__)
class AkkudoktorElecPriceMeta(PydanticBaseModel):
start_timestamp: str
@@ -102,13 +104,12 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
- add the file cache again.
"""
source = "https://api.akkudoktor.net"
if not self.start_datetime:
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
assert self.start_datetime # mypy fix
# Try to take data from 5 weeks back for prediction
date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.general.timezone}"
response = requests.get(url, timeout=10)
response = requests.get(url)
logger.debug(f"Response from {url}: {response}")
response.raise_for_status() # Raise an error for bad responses
akkudoktor_data = self._validate_data(response.content)
@@ -147,8 +148,7 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
"""
# Get Akkudoktor electricity price data
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
if not self.start_datetime:
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
assert self.start_datetime # mypy fix
# Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps.
@@ -178,10 +178,7 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
)
amount_datasets = len(self.records)
if not highest_orig_datetime: # mypy fix
error_msg = f"Highest original datetime not available: {highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
assert highest_orig_datetime # mypy fix
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
needed_hours = int(

View File

@@ -1,257 +0,0 @@
"""Retrieves and processes electricity price forecast data from Energy-Charts.
This module provides classes and mappings to manage electricity price data obtained from the
Energy-Charts API, including support for various electricity price attributes such as temperature,
humidity, cloud cover, and solar irradiance. The data is mapped to the `ElecPriceDataRecord`
format, enabling consistent access to forecasted and historical electricity price attributes.
"""
from datetime import datetime
from typing import Any, List, Optional, Union
import numpy as np
import pandas as pd
import requests
from loguru import logger
from pydantic import ValidationError
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
class EnergyChartsElecPrice(PydanticBaseModel):
license_info: str
unix_seconds: List[int]
price: List[float]
unit: str
deprecated: bool
class ElecPriceEnergyCharts(ElecPriceProvider):
"""Fetch and process electricity price forecast data from Energy-Charts.
ElecPriceEnergyCharts is a singleton-based class that retrieves electricity price forecast data
from the Energy-Charts API and maps it to `ElecPriceDataRecord` fields, applying
any necessary scaling or unit corrections. It manages the forecast over a range
of hours into the future and retains historical data.
Attributes:
hours (int, optional): Number of hours in the future for the forecast.
historic_hours (int, optional): Number of past hours for retaining data.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
Methods:
provider_id(): Returns a unique identifier for the provider.
_request_forecast(): Fetches the forecast from the Energy-Charts API.
_update_data(): Processes and updates forecast data from Energy-Charts in ElecPriceDataRecord format.
"""
highest_orig_datetime: Optional[datetime] = None
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the Energy-Charts provider."""
return "ElecPriceEnergyCharts"
@classmethod
def _validate_data(cls, json_str: Union[bytes, Any]) -> EnergyChartsElecPrice:
"""Validate Energy-Charts Electricity Price forecast data."""
try:
energy_charts_data = EnergyChartsElecPrice.model_validate_json(json_str)
except ValidationError as e:
error_msg = ""
for error in e.errors():
field = " -> ".join(str(x) for x in error["loc"])
message = error["msg"]
error_type = error["type"]
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
logger.error(f"Energy-Charts schema change: {error_msg}")
raise ValueError(error_msg)
return energy_charts_data
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
"""Fetch electricity price forecast data from Energy-Charts API.
This method sends a request to Energy-Charts API to retrieve forecast data for a specified
date range. The response data is parsed and returned as JSON for further processing.
Returns:
dict: The parsed JSON response from Energy-Charts API containing forecast data.
Raises:
ValueError: If the API response does not include expected `electricity price` data.
"""
source = "https://api.energy-charts.info"
if start_date is None:
# Try to take data from 5 weeks back for prediction
start_date = to_datetime(
self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD"
)
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
url = f"{source}/price?bzn=DE-LU&start={start_date}&end={last_date}"
response = requests.get(url, timeout=30)
logger.debug(f"Response from {url}: {response}")
response.raise_for_status() # Raise an error for bad responses
energy_charts_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return energy_charts_data
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
# Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps.
# Get charges_kwh in wh
charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
# Initialize
highest_orig_datetime = None # newest datetime from the api after that we want to update.
series_data = pd.Series(dtype=float) # Initialize an empty series
# Iterate over timestamps and prices together
for unix_sec, price_eur_per_mwh in zip(
energy_charts_data.unix_seconds, energy_charts_data.price
):
orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone)
# Track the latest datetime
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
highest_orig_datetime = orig_datetime
# Convert EUR/MWh to EUR/Wh, apply charges and VAT if charges > 0
if charges_wh > 0:
vat_rate = self.config.elecprice.vat_rate or 1.19
price_wh = ((price_eur_per_mwh / 1_000_000) + charges_wh) * vat_rate
else:
price_wh = price_eur_per_mwh / 1_000_000
# Store in series
series_data.at[orig_datetime] = price_wh
return series_data
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean()
std = data.std()
lower_bound = mean - sigma * std
upper_bound = mean + sigma * std
capped_data = data.clip(min=lower_bound, max=upper_bound)
return capped_data
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit()
return model.forecast(hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history))
def _update_data(
self, force_update: Optional[bool] = False
) -> None: # tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Update forecast data in the ElecPriceDataRecord format.
Retrieves data from Energy-Charts, maps each Energy-Charts field to the corresponding
`ElecPriceDataRecord` and applies any necessary scaling.
The final mapped and processed data is inserted into the sequence as `ElecPriceDataRecord`.
"""
# New prices are available every day at 14:00
now = pd.Timestamp.now(tz=self.config.general.timezone)
midnight = now.normalize()
hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47
end = midnight + pd.Timedelta(hours=hours_ahead)
if not self.start_datetime:
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
# Determine if update is needed and how many days
past_days = 35
if self.highest_orig_datetime:
history_series = self.key_to_series(
key="elecprice_marketprice_wh", start_datetime=self.start_datetime
)
# If history lower, then start_datetime
if history_series.index.min() <= self.start_datetime:
past_days = 0
needs_update = end > self.highest_orig_datetime
else:
needs_update = True
if needs_update:
logger.info(
f"Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
)
# Set start_date try to take data from 5 weeks back for prediction
start_date = to_datetime(
self.start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD"
)
# Get Energy-Charts electricity price data
energy_charts_data = self._request_forecast(
start_date=start_date, force_update=force_update
) # type: ignore
# Parse and store data
series_data = self._parse_data(energy_charts_data)
self.highest_orig_datetime = series_data.index.max()
self.key_from_series("elecprice_marketprice_wh", series_data)
else:
logger.info(
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
)
# Generate history array for prediction
history = self.key_to_array(
key="elecprice_marketprice_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
)
amount_datasets = len(self.records)
if not self.highest_orig_datetime: # mypy fix
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
needed_hours = int(
self.config.prediction.hours
- ((self.highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
)
if needed_hours <= 0:
logger.warning(
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.start_datetime}"
) # this might keep data longer than self.start_datetime + self.config.prediction.hours in the records
return
if amount_datasets > 800: # we do the full ets with seasons of 1 week
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
elif amount_datasets > 0: # not enough data for ets, do median
prediction = self._predict_median(history, hours=needed_hours)
else:
logger.error("No data available for prediction")
raise ValueError("No data available")
# write predictions into the records, update if exist.
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
for i in range(len(prediction))
],
)
self.key_from_series("elecprice_marketprice_wh", prediction_series)

View File

@@ -9,13 +9,15 @@ format, enabling consistent access to forecasted and historical elecprice attrib
from pathlib import Path
from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
logger = get_logger(__name__)
class ElecPriceImportCommonSettings(SettingsBaseModel):
"""Common settings for elecprice data import from file or JSON String."""
@@ -61,9 +63,6 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
return "ElecPriceImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.elecprice.provider_settings is None:
logger.debug(f"{self.provider_id()} data update without provider settings.")
return
if self.config.elecprice.provider_settings.import_file_path:
self.import_from_file(
self.config.elecprice.provider_settings.import_file_path,

View File

@@ -14,7 +14,7 @@ class SelfConsumptionProbabilityInterpolator:
self.filepath = filepath
# Load the RegularGridInterpolator
with open(self.filepath, "rb") as file:
self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301
self.interpolator: RegularGridInterpolator = pickle.load(file)
@lru_cache(maxsize=128)
def generate_points(

View File

@@ -2,23 +2,14 @@
from typing import Optional, Union
from pydantic import Field, field_validator
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
from akkudoktoreos.prediction.loadvrm import LoadVrmCommonSettings
from akkudoktoreos.prediction.prediction import get_prediction
prediction_eos = get_prediction()
# Valid load providers
load_providers = [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, LoadProvider)
]
logger = get_logger(__name__)
class LoadCommonSettings(SettingsBaseModel):
@@ -30,14 +21,6 @@ class LoadCommonSettings(SettingsBaseModel):
examples=["LoadAkkudoktor"],
)
provider_settings: Optional[
Union[LoadAkkudoktorCommonSettings, LoadVrmCommonSettings, LoadImportCommonSettings]
] = Field(default=None, description="Provider settings", examples=[None])
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in load_providers:
return value
raise ValueError(f"Provider '{value}' is not a valid load provider: {load_providers}.")
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
Field(default=None, description="Provider settings", examples=[None])
)

View File

@@ -9,8 +9,11 @@ from typing import List, Optional
from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
logger = get_logger(__name__)
class LoadDataRecord(PredictionRecord):
"""Represents a load data record containing various load attributes at a specific datetime."""

View File

@@ -3,13 +3,15 @@
from typing import Optional
import numpy as np
from loguru import logger
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
logger = get_logger(__name__)
class LoadAkkudoktorCommonSettings(SettingsBaseModel):
"""Common settings for load data import from file."""
@@ -120,11 +122,10 @@ class LoadAkkudoktor(LoadProvider):
}
if date.day_of_week < 5:
# Monday to Friday (0..4)
value_adjusted = hourly_stats[0] + weekday_adjust[date.hour]
values["load_mean_adjusted"] = hourly_stats[0] + weekday_adjust[date.hour]
else:
# Saturday, Sunday (5, 6)
value_adjusted = hourly_stats[0] + weekend_adjust[date.hour]
values["load_mean_adjusted"] = max(0, value_adjusted)
values["load_mean_adjusted"] = hourly_stats[0] + weekend_adjust[date.hour]
self.update_value(date, values)
date += to_duration("1 hour")
# We are working on fresh data (no cache), report update time

View File

@@ -9,13 +9,15 @@ format, enabling consistent access to forecasted and historical load attributes.
from pathlib import Path
from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
logger = get_logger(__name__)
class LoadImportCommonSettings(SettingsBaseModel):
"""Common settings for load data import from file or JSON string."""
@@ -60,9 +62,6 @@ class LoadImport(LoadProvider, PredictionImportProvider):
return "LoadImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.load.provider_settings is None:
logger.debug(f"{self.provider_id()} data update without provider settings.")
return
if self.config.load.provider_settings.import_file_path:
self.import_from_file(self.config.provider_settings.import_file_path, key_prefix="load")
if self.config.load.provider_settings.import_json:

View File

@@ -1,109 +0,0 @@
"""Retrieves load forecast data from VRM API."""
from typing import Any, Optional, Union
import requests
from loguru import logger
from pendulum import DateTime
from pydantic import Field, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.utils.datetimeutil import to_datetime
class VrmForecastRecords(PydanticBaseModel):
vrm_consumption_fc: list[tuple[int, float]]
solar_yield_forecast: list[tuple[int, float]]
class VrmForecastResponse(PydanticBaseModel):
success: bool
records: VrmForecastRecords
totals: dict
class LoadVrmCommonSettings(SettingsBaseModel):
"""Common settings for VRM API."""
load_vrm_token: str = Field(
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
)
load_vrm_idsite: int = Field(default=12345, description="VRM-Installation-ID", examples=[12345])
class LoadVrm(LoadProvider):
"""Fetch Load forecast data from VRM API."""
@classmethod
def provider_id(cls) -> str:
return "LoadVrm"
@classmethod
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
"""Validate the VRM API load forecast response."""
try:
return VrmForecastResponse.model_validate_json(json_str)
except ValidationError as e:
error_msg = "\n".join(
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
f"Error: {err['msg']}\nType: {err['type']}"
for err in e.errors()
)
logger.error(f"VRM-API schema validation failed:\n{error_msg}")
raise ValueError(error_msg)
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
"""Fetch forecast data from Victron VRM API."""
base_url = "https://vrmapi.victronenergy.com/v2/installations"
installation_id = self.config.load.provider_settings.load_vrm_idsite
api_token = self.config.load.provider_settings.load_vrm_token
url = f"{base_url}/{installation_id}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
logger.debug(f"Requesting VRM load forecast: {url}")
try:
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
except requests.RequestException as e:
logger.error(f"Error during VRM API request: {e}")
raise RuntimeError("Failed to fetch load forecast from VRM API") from e
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return self._validate_data(response.content)
def _ts_to_datetime(self, timestamp: int) -> DateTime:
"""Convert UNIX ms timestamp to timezone-aware datetime."""
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Fetch and store VRM load forecast as load_mean and related values."""
start_date = self.start_datetime.start_of("day")
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
start_ts = int(start_date.timestamp())
end_ts = int(end_date.timestamp())
logger.info(f"Updating Load forecast from VRM: {start_date} to {end_date}")
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
load_mean_data = []
for timestamp, value in vrm_forecast_data.records.vrm_consumption_fc:
date = self._ts_to_datetime(timestamp)
rounded_value = round(value, 2)
self.update_value(
date,
{"load_mean": rounded_value, "load_std": 0.0, "load_mean_adjusted": rounded_value},
)
load_mean_data.append((date, rounded_value))
logger.debug(f"Updated load_mean with {len(load_mean_data)} entries.")
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
if __name__ == "__main__":
lv = LoadVrm()
lv._update_data()

View File

@@ -32,15 +32,12 @@ from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
from akkudoktoreos.prediction.loadimport import LoadImport
from akkudoktoreos.prediction.loadvrm import LoadVrm
from akkudoktoreos.prediction.predictionabc import PredictionContainer
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport
@@ -86,13 +83,10 @@ class Prediction(PredictionContainer):
providers: List[
Union[
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceImport,
LoadAkkudoktor,
LoadVrm,
LoadImport,
PVForecastAkkudoktor,
PVForecastVrm,
PVForecastImport,
WeatherBrightSky,
WeatherClearOutside,
@@ -103,13 +97,10 @@ class Prediction(PredictionContainer):
# Initialize forecast providers, all are singletons.
elecprice_akkudoktor = ElecPriceAkkudoktor()
elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_import = ElecPriceImport()
load_akkudoktor = LoadAkkudoktor()
load_vrm = LoadVrm()
load_import = LoadImport()
pvforecast_akkudoktor = PVForecastAkkudoktor()
pvforecast_vrm = PVForecastVrm()
pvforecast_import = PVForecastImport()
weather_brightsky = WeatherBrightSky()
weather_clearoutside = WeatherClearOutside()
@@ -123,13 +114,10 @@ def get_prediction() -> Prediction:
prediction = Prediction(
providers=[
elecprice_akkudoktor,
elecprice_energy_charts,
elecprice_import,
load_akkudoktor,
load_vrm,
load_import,
pvforecast_akkudoktor,
pvforecast_vrm,
pvforecast_import,
weather_brightsky,
weather_clearoutside,

View File

@@ -10,7 +10,6 @@ and manipulation of configuration and prediction data in a clear, scalable, and
from typing import List, Optional
from loguru import logger
from pendulum import DateTime
from pydantic import Field, computed_field
@@ -23,8 +22,11 @@ from akkudoktoreos.core.dataabc import (
DataRecord,
DataSequence,
)
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.utils.datetimeutil import to_duration
logger = get_logger(__name__)
class PredictionBase(DataBase, MeasurementMixin):
"""Base class for handling prediction data.
@@ -204,6 +206,9 @@ class PredictionProvider(PredictionStartEndKeepMixin, DataProvider):
force_enable (bool, optional): If True, forces the update even if the provider is disabled.
force_update (bool, optional): If True, forces the provider to update the data even if still cached.
"""
# Update prediction configuration
self.config.update()
# Check after configuration is updated.
if not force_enable and not self.enabled():
return

View File

@@ -1,24 +1,15 @@
"""PV forecast module for PV power predictions."""
from typing import Any, List, Optional, Self, Union
from typing import Any, ClassVar, List, Optional, Self
from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.prediction import get_prediction
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
from akkudoktoreos.prediction.pvforecastvrm import PVforecastVrmCommonSettings
from akkudoktoreos.utils.docs import get_model_structure_from_examples
prediction_eos = get_prediction()
# Valid PV forecast providers
pvforecast_providers = [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, PVForecastProvider)
]
logger = get_logger(__name__)
class PVForecastPlaneSetting(SettingsBaseModel):
@@ -26,18 +17,14 @@ class PVForecastPlaneSetting(SettingsBaseModel):
# latitude: Optional[float] = Field(default=None, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)")
surface_tilt: Optional[float] = Field(
default=30.0,
ge=0.0,
le=90.0,
default=None,
description="Tilt angle from horizontal plane. Ignored for two-axis tracking.",
examples=[10.0, 20.0],
)
surface_azimuth: Optional[float] = Field(
default=180.0,
ge=0.0,
le=360.0,
default=None,
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
examples=[180.0, 90.0],
examples=[10.0, 20.0],
)
userhorizon: Optional[List[float]] = Field(
default=None,
@@ -84,7 +71,7 @@ class PVForecastPlaneSetting(SettingsBaseModel):
default=None, description="Model of the inverter of this plane.", examples=[None]
)
inverter_paco: Optional[int] = Field(
default=None, description="AC power rating of the inverter [W].", examples=[6000, 4000]
default=None, description="AC power rating of the inverter. [W]", examples=[6000, 4000]
)
modules_per_string: Optional[int] = Field(
default=None,
@@ -135,30 +122,24 @@ class PVForecastCommonSettings(SettingsBaseModel):
examples=["PVForecastAkkudoktor"],
)
provider_settings: Optional[
Union[PVForecastImportCommonSettings, PVforecastVrmCommonSettings]
] = Field(default=None, description="Provider settings", examples=[None])
planes: Optional[list[PVForecastPlaneSetting]] = Field(
default=None,
description="Plane configuration.",
examples=[get_model_structure_from_examples(PVForecastPlaneSetting, True)],
)
max_planes: Optional[int] = Field(
default=0,
ge=0,
description="Maximum number of planes that can be set",
)
max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in pvforecast_providers:
return value
raise ValueError(
f"Provider '{value}' is not a valid PV forecast provider: {pvforecast_providers}."
@field_validator("planes")
def validate_planes(
cls, planes: Optional[list[PVForecastPlaneSetting]]
) -> Optional[list[PVForecastPlaneSetting]]:
if planes is not None and len(planes) > cls.max_planes:
raise ValueError(f"Maximum number of supported planes: {cls.max_planes}.")
return planes
provider_settings: Optional[PVForecastImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
)
## Computed fields

View File

@@ -7,11 +7,13 @@ Notes:
from abc import abstractmethod
from typing import List, Optional
from loguru import logger
from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
logger = get_logger(__name__)
class PVForecastDataRecord(PredictionRecord):
"""Represents a pvforecast data record containing various pvforecast attributes at a specific datetime."""

View File

@@ -27,14 +27,14 @@ Example:
"planes": [
{
"peakpower": 5.0,
"surface_azimuth": 170,
"surface_azimuth": -10,
"surface_tilt": 7,
"userhorizon": [20, 27, 22, 20],
"inverter_paco": 10000,
},
{
"peakpower": 4.8,
"surface_azimuth": 90,
"surface_azimuth": -90,
"surface_tilt": 7,
"userhorizon": [30, 30, 30, 50],
"inverter_paco": 10000,
@@ -78,22 +78,24 @@ Methods:
from typing import Any, List, Optional, Union
import requests
from loguru import logger
from pydantic import Field, ValidationError, computed_field, field_validator
from pydantic import Field, ValidationError, computed_field
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.pvforecastabc import (
PVForecastDataRecord,
PVForecastProvider,
)
from akkudoktoreos.utils.cacheutil import cache_in_file
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
logger = get_logger(__name__)
class AkkudoktorForecastHorizon(PydanticBaseModel):
altitude: int
azimuthFrom: float
azimuthTo: float
azimuthFrom: int
azimuthTo: int
class AkkudoktorForecastMeta(PydanticBaseModel):
@@ -112,30 +114,6 @@ class AkkudoktorForecastMeta(PydanticBaseModel):
horizont: List[List[AkkudoktorForecastHorizon]]
horizontString: List[str]
@field_validator("power", "azimuth", "tilt", "powerInverter", mode="before")
@classmethod
def ensure_list(cls, v: Any) -> List[int]:
return v if isinstance(v, list) else [v]
@field_validator("horizont", mode="before")
@classmethod
def normalize_horizont(cls, v: Any) -> List[List[AkkudoktorForecastHorizon]]:
if isinstance(v, list):
# Case: flat list of dicts
if v and isinstance(v[0], dict):
return [v]
# Already in correct nested form
if v and isinstance(v[0], list):
return v
return v
@field_validator("horizontString", mode="before")
@classmethod
def parse_horizont_string(cls, v: Any) -> List[str]:
if isinstance(v, str):
return [s.strip() for s in v.split(",")]
return v
class AkkudoktorForecastValue(PydanticBaseModel):
datetime: str
@@ -243,18 +221,13 @@ class PVForecastAkkudoktor(PVForecastProvider):
for i in range(len(self.config.pvforecast.planes)):
query_params.append(f"power={int(self.config.pvforecast.planes_peakpower[i] * 1000)}")
# EOS orientation of of pv modules in azimuth in degree:
# north=0, east=90, south=180, west=270
# Akkudoktor orientation of pv modules in azimuth in degree:
# north=+-180, east=-90, south=0, west=90
azimuth_akkudoktor = int(self.config.pvforecast.planes_azimuth[i]) - 180
query_params.append(f"azimuth={azimuth_akkudoktor}")
query_params.append(f"azimuth={int(self.config.pvforecast.planes_azimuth[i])}")
query_params.append(f"tilt={int(self.config.pvforecast.planes_tilt[i])}")
query_params.append(
f"powerInverter={int(self.config.pvforecast.planes_inverter_paco[i])}"
)
horizon_values = ",".join(
str(round(h)) for h in self.config.pvforecast.planes_userhorizon[i]
str(int(h)) for h in self.config.pvforecast.planes_userhorizon[i]
)
query_params.append(f"horizont={horizon_values}")
@@ -289,12 +262,12 @@ class PVForecastAkkudoktor(PVForecastProvider):
Raises:
ValueError: If the API response does not include expected `meta` data.
"""
response = requests.get(self._url(), timeout=10)
response = requests.get(self._url())
response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {self._url()}: {response}")
akkudoktor_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return akkudoktor_data
def _update_data(self, force_update: Optional[bool] = False) -> None:
@@ -330,8 +303,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg)
if not self.start_datetime:
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
assert self.start_datetime # mypy fix
# Iterate over forecast data points
for forecast_values in zip(*akkudoktor_data.values):
@@ -418,28 +390,28 @@ if __name__ == "__main__":
"planes": [
{
"peakpower": 5.0,
"surface_azimuth": 170,
"surface_azimuth": -10,
"surface_tilt": 7,
"userhorizon": [20, 27, 22, 20],
"inverter_paco": 10000,
},
{
"peakpower": 4.8,
"surface_azimuth": 90,
"surface_azimuth": -90,
"surface_tilt": 7,
"userhorizon": [30, 30, 30, 50],
"inverter_paco": 10000,
},
{
"peakpower": 1.4,
"surface_azimuth": 140,
"surface_azimuth": -40,
"surface_tilt": 60,
"userhorizon": [60, 30, 0, 30],
"inverter_paco": 2000,
},
{
"peakpower": 1.6,
"surface_azimuth": 185,
"surface_azimuth": 5,
"surface_tilt": 45,
"userhorizon": [45, 25, 30, 60],
"inverter_paco": 1400,

View File

@@ -9,13 +9,15 @@ format, enabling consistent access to forecasted and historical pvforecast attri
from pathlib import Path
from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
logger = get_logger(__name__)
class PVForecastImportCommonSettings(SettingsBaseModel):
"""Common settings for pvforecast data import from file or JSON string."""
@@ -61,9 +63,6 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
return "PVForecastImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.pvforecast.provider_settings is None:
logger.debug(f"{self.provider_id()} data update without provider settings.")
return
if self.config.pvforecast.provider_settings.import_file_path is not None:
self.import_from_file(
self.config.pvforecast.provider_settings.import_file_path,

View File

@@ -1,110 +0,0 @@
"""Retrieves pvforecast data from VRM API."""
from typing import Any, Optional, Union
import requests
from loguru import logger
from pendulum import DateTime
from pydantic import Field, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
from akkudoktoreos.utils.datetimeutil import to_datetime
class VrmForecastRecords(PydanticBaseModel):
vrm_consumption_fc: list[tuple[int, float]]
solar_yield_forecast: list[tuple[int, float]]
class VrmForecastResponse(PydanticBaseModel):
success: bool
records: VrmForecastRecords
totals: dict
class PVforecastVrmCommonSettings(SettingsBaseModel):
"""Common settings for VRM API."""
pvforecast_vrm_token: str = Field(
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
)
pvforecast_vrm_idsite: int = Field(
default=12345, description="VRM-Installation-ID", examples=[12345]
)
class PVForecastVrm(PVForecastProvider):
"""Fetch and process PV forecast data from VRM API."""
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the PV-Forecast-Provider."""
return "PVForecastVrm"
@classmethod
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
"""Validate the VRM forecast response data against the expected schema."""
try:
return VrmForecastResponse.model_validate_json(json_str)
except ValidationError as e:
error_msg = "\n".join(
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
f"Error: {err['msg']}\nType: {err['type']}"
for err in e.errors()
)
logger.error(f"VRM-API schema change:\n{error_msg}")
raise ValueError(error_msg)
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
"""Fetch forecast data from Victron VRM API."""
source = "https://vrmapi.victronenergy.com/v2/installations"
id_site = self.config.pvforecast.provider_settings.pvforecast_vrm_idsite
api_token = self.config.pvforecast.provider_settings.pvforecast_vrm_token
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
url = f"{source}/{id_site}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
logger.debug(f"Requesting VRM forecast: {url}")
try:
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
except requests.RequestException as e:
logger.error(f"Failed to fetch pvforecast: {e}")
raise RuntimeError("Failed to fetch pvforecast from VRM API") from e
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return self._validate_data(response.content)
def _ts_to_datetime(self, timestamp: int) -> DateTime:
"""Convert UNIX ms timestamp to timezone-aware datetime."""
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update forecast data in the PVForecastDataRecord format."""
start_date = self.start_datetime.start_of("day")
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
start_ts = int(start_date.timestamp())
end_ts = int(end_date.timestamp())
logger.info(f"Updating PV forecast from VRM: {start_date} to {end_date}")
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
pv_forecast = []
for timestamp, value in vrm_forecast_data.records.solar_yield_forecast:
date = self._ts_to_datetime(timestamp)
dc_power = round(value, 2)
ac_power = round(dc_power * 0.96, 2)
self.update_value(
date, {"pvforecast_dc_power": dc_power, "pvforecast_ac_power": ac_power}
)
pv_forecast.append((date, dc_power))
logger.debug(f"Updated pvforecast_dc_power with {len(pv_forecast)} entries.")
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
# Example usage
if __name__ == "__main__":
pv = PVForecastVrm()
pv._update_data()

View File

@@ -2,22 +2,11 @@
from typing import Optional
from pydantic import Field, field_validator
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.prediction import get_prediction
from akkudoktoreos.prediction.weatherabc import WeatherProvider
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
prediction_eos = get_prediction()
# Valid weather providers
weather_providers = [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, WeatherProvider)
]
class WeatherCommonSettings(SettingsBaseModel):
"""Weather Forecast Configuration."""
@@ -31,13 +20,3 @@ class WeatherCommonSettings(SettingsBaseModel):
provider_settings: Optional[WeatherImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None]
)
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in weather_providers:
return value
raise ValueError(
f"Provider '{value}' is not a valid weather provider: {weather_providers}."
)

View File

@@ -14,8 +14,11 @@ import pandas as pd
import pvlib
from pydantic import Field
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
logger = get_logger(__name__)
class WeatherDataRecord(PredictionRecord):
"""Represents a weather data record containing various weather attributes at a specific datetime.

View File

@@ -7,21 +7,23 @@ format, enabling consistent access to forecasted and historical weather attribut
"""
import json
from typing import Dict, List, Optional, Tuple, Union
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import pvlib
import requests
from loguru import logger
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
from akkudoktoreos.utils.cacheutil import cache_in_file
from akkudoktoreos.utils.datetimeutil import to_datetime
WheaterDataBrightSkyMapping: List[Tuple[str, Optional[str], Optional[Union[str, float]]]] = [
logger = get_logger(__name__)
WheaterDataBrightSkyMapping: List[Tuple[str, Optional[str], Optional[float]]] = [
# brightsky_key, description, corr_factor
("timestamp", "DateTime", "to datetime in timezone"),
("timestamp", "DateTime", None),
("precipitation", "Precipitation Amount (mm)", 1),
("pressure_msl", "Pressure (mb)", 1),
("sunshine", None, None),
@@ -94,11 +96,10 @@ class WeatherBrightSky(WeatherProvider):
ValueError: If the API response does not include expected `weather` data.
"""
source = "https://api.brightsky.dev"
date = to_datetime(self.start_datetime, as_string=True)
last_date = to_datetime(self.end_datetime, as_string=True)
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD")
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
response = requests.get(
f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}",
timeout=10,
f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}"
)
response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}")
@@ -132,8 +133,7 @@ class WeatherBrightSky(WeatherProvider):
error_msg = f"No WeatherDataRecord key for '{description}'"
logger.error(error_msg)
raise ValueError(error_msg)
series = self.key_to_series(key)
return series
return self.key_to_series(key)
def _description_from_series(self, description: str, data: pd.Series) -> None:
"""Update a weather data with a pandas Series based on its description.
@@ -170,7 +170,7 @@ class WeatherBrightSky(WeatherProvider):
brightsky_data = self._request_forecast(force_update=force_update) # type: ignore
# Get key mapping from description
brightsky_key_mapping: Dict[str, Tuple[Optional[str], Optional[Union[str, float]]]] = {}
brightsky_key_mapping: Dict[str, Tuple[Optional[str], Optional[float]]] = {}
for brightsky_key, description, corr_factor in WheaterDataBrightSkyMapping:
if description is None:
brightsky_key_mapping[brightsky_key] = (None, None)
@@ -192,9 +192,6 @@ class WeatherBrightSky(WeatherProvider):
value = brightsky_record[brightsky_key]
corr_factor = item[1]
if value and corr_factor:
if corr_factor == "to datetime in timezone":
value = to_datetime(value, in_timezone=self.config.general.timezone)
else:
value = value * corr_factor
setattr(weather_record, key, value)
self.insert_by_datetime(weather_record)
@@ -219,40 +216,14 @@ class WeatherBrightSky(WeatherProvider):
self._description_from_series(description, dhi)
# Add Preciptable Water (PWAT) with a PVLib method.
key = WeatherDataRecord.key_from_description("Temperature (°C)")
assert key # noqa: S101
temperature = self.key_to_array(
key=key,
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=to_duration("1 hour"),
)
if any(x is None or isinstance(x, float) and np.isnan(x) for x in temperature):
# We can not calculate PWAT
debug_msg = f"Innvalid temperature '{temperature}'"
logger.debug(debug_msg)
return
key = WeatherDataRecord.key_from_description("Relative Humidity (%)")
assert key # noqa: S101
humidity = self.key_to_array(
key=key,
start_datetime=self.start_datetime,
end_datetime=self.end_datetime,
interval=to_duration("1 hour"),
)
if any(x is None or isinstance(x, float) and np.isnan(x) for x in humidity):
# We can not calculate PWAT
debug_msg = f"Innvalid humidity '{humidity}'"
logger.debug(debug_msg)
return
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
description = "Temperature (°C)"
temperature = self._description_to_series(description)
description = "Relative Humidity (%)"
humidity = self._description_to_series(description)
pwat = pd.Series(
data=data,
index=pd.DatetimeIndex(
pd.date_range(
start=self.start_datetime, end=self.end_datetime, freq="1h", inclusive="left"
)
),
data=pvlib.atmosphere.gueymard94_pw(temperature, humidity), index=temperature.index
)
description = "Preciptable Water (cm)"
self._description_from_series(description, pwat)

View File

@@ -18,12 +18,15 @@ from typing import Dict, List, Optional, Tuple
import pandas as pd
import requests
from bs4 import BeautifulSoup
from loguru import logger
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
from akkudoktoreos.utils.cacheutil import cache_in_file
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_timezone
logger = get_logger(__name__)
WheaterDataClearOutsideMapping: List[Tuple[str, Optional[str], Optional[float]]] = [
# clearoutside_key, description, corr_factor
("DateTime", "DateTime", None),
@@ -85,12 +88,12 @@ class WeatherClearOutside(WeatherProvider):
"""Requests weather forecast from ClearOutside.
Returns:
response: Weather forecast request response from ClearOutside.
response: Weather forecast request reponse from ClearOutside.
"""
source = "https://clearoutside.com/forecast"
latitude = round(self.config.general.latitude, 2)
longitude = round(self.config.general.longitude, 2)
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true", timeout=10)
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}")
# We are working on fresh data (no cache), report update time

View File

@@ -9,13 +9,15 @@ format, enabling consistent access to forecasted and historical weather attribut
from pathlib import Path
from typing import Optional, Union
from loguru import logger
from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
from akkudoktoreos.prediction.weatherabc import WeatherProvider
logger = get_logger(__name__)
class WeatherImportCommonSettings(SettingsBaseModel):
"""Common settings for weather data import from file or JSON string."""
@@ -61,9 +63,6 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
return "WeatherImport"
def _update_data(self, force_update: Optional[bool] = False) -> None:
if self.config.weather.provider_settings is None:
logger.debug(f"{self.provider_id()} data update without provider settings.")
return
if self.config.weather.provider_settings.import_file_path:
self.import_from_file(
self.config.weather.provider_settings.import_file_path, key_prefix="weather"

View File

@@ -1,297 +0,0 @@
"""Admin UI components for EOS Dashboard.
This module provides functions to generate administrative UI components
for the EOS dashboard.
"""
import json
from pathlib import Path
from typing import Any, Optional, Union
import requests
from fasthtml.common import Select
from loguru import logger
from monsterui.foundations import stringify
from monsterui.franken import ( # Select, TODO: Select from FrankenUI does not work - using Select from FastHTML instead
H3,
Button,
ButtonT,
Card,
Details,
Div,
DivHStacked,
DividerLine,
Grid,
Input,
Options,
P,
Summary,
UkIcon,
)
from platformdirs import user_config_dir
from akkudoktoreos.server.dash.components import Error, Success
from akkudoktoreos.server.dash.configuration import get_nested_value
from akkudoktoreos.utils.datetimeutil import to_datetime
# Directory to export files to, or to import files from
export_import_directory = Path(user_config_dir("net.akkudoktor.eosdash", "akkudoktor"))
def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
"""Creates a styled button for administrative actions.
Args:
*c (Any): Positional arguments representing the button's content.
cls (Optional[Union[str, tuple]]): Additional CSS classes for styling. Defaults to None.
**kwargs (Any): Additional keyword arguments passed to the `Button`.
Returns:
Button: A styled `Button` component for admin actions.
"""
new_cls = f"{ButtonT.primary}"
if cls:
new_cls += f" {stringify(cls)}"
kwargs["cls"] = new_cls
return Button(*c, submit=False, **kwargs)
def AdminConfig(
eos_host: str, eos_port: Union[str, int], data: Optional[dict], config: Optional[dict[str, Any]]
) -> tuple[str, Union[Card, list[Card]]]:
"""Creates a configuration management card with save-to-file functionality.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
data (Optional[dict]): Incoming data containing action and category for processing.
Returns:
tuple[str, Union[Card, list[Card]]]: A tuple containing the configuration category label and the `Card` UI component.
"""
server = f"http://{eos_host}:{eos_port}"
eos_hostname = "EOS server"
eosdash_hostname = "EOSdash server"
category = "configuration"
# save config file
status = (None,)
config_file_path = "<unknown>"
try:
if config:
config_file_path = get_nested_value(config, ["general", "config_file_path"])
except Exception as e:
logger.debug(f"general.config_file_path: {e}")
# export config file
export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss")
export_to_file_status = (None,)
# import config file
import_from_file_status = (None,)
if data and data.get("category", None) == category:
# This data is for us
if data["action"] == "save_to_file":
# Safe current configuration to file
try:
result = requests.put(f"{server}/v1/config/file", timeout=10)
result.raise_for_status()
config_file_path = result.json()["general"]["config_file_path"]
status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'")
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
status = Error(
f"Can not save actual config to file on '{eos_hostname}': {e}, {detail}"
)
except Exception as e:
status = Error(f"Can not save actual config to file on '{eos_hostname}': {e}")
elif data["action"] == "export_to_file":
# Export current configuration to file
export_to_file_tag = data.get("export_to_file_tag", export_to_file_next_tag)
export_to_file_path = export_import_directory.joinpath(
f"eos_config_{export_to_file_tag}.json"
)
try:
if not config:
raise ValueError(f"No config from '{eos_hostname}'")
export_to_file_path.parent.mkdir(parents=True, exist_ok=True)
with export_to_file_path.open("w", encoding="utf-8", newline="\n") as fd:
json.dump(config, fd, indent=4, sort_keys=True)
export_to_file_status = Success(
f"Exported to '{export_to_file_path}' on '{eosdash_hostname}'"
)
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
export_to_file_status = Error(
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}, {detail}"
)
except Exception as e:
export_to_file_status = Error(
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}"
)
elif data["action"] == "import_from_file":
import_file_name = data.get("import_file_name", None)
import_from_file_pathes = list(
export_import_directory.glob("*.json")
) # expand generator object
import_file_path = None
for f in import_from_file_pathes:
if f.name == import_file_name:
import_file_path = f
if import_file_path:
try:
with import_file_path.open("r", encoding="utf-8", newline=None) as fd:
import_config = json.load(fd)
result = requests.put(f"{server}/v1/config", json=import_config, timeout=10)
result.raise_for_status()
import_from_file_status = Success(
f"Config imported from '{import_file_path}' on '{eosdash_hostname}'"
)
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
import_from_file_status = Error(
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}, {detail}"
)
except Exception as e:
import_from_file_status = Error(
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}"
)
else:
import_from_file_status = Error(
f"Can not import config from '{import_file_name}', not found in '{export_import_directory}' on '{eosdash_hostname}'"
)
# Update for display, in case we added a new file before
import_from_file_names = [f.name for f in list(export_import_directory.glob("*.json"))]
return (
category,
[
Card(
Details(
Summary(
Grid(
DivHStacked(
UkIcon(icon="play"),
AdminButton(
"Save to file",
hx_post="/eosdash/admin",
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='{"category": "configuration", "action": "save_to_file"}',
),
P(f"'{config_file_path}' on '{eos_hostname}'"),
),
status,
),
cls="list-none",
),
P(f"Safe actual configuration to '{config_file_path}' on '{eos_hostname}'."),
),
),
Card(
Details(
Summary(
Grid(
DivHStacked(
UkIcon(icon="play"),
AdminButton(
"Export to file",
hx_post="/eosdash/admin",
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='js:{"category": "configuration", "action": "export_to_file", "export_to_file_tag": document.querySelector("[name=\'chosen_export_file_tag\']").value }',
),
P("'eos_config_"),
Input(
id="export_file_tag",
name="chosen_export_file_tag",
value=export_to_file_next_tag,
),
P(".json'"),
),
export_to_file_status,
),
cls="list-none",
),
P(
f"Export actual configuration to 'eos_config_{export_to_file_next_tag}.json' on '{eosdash_hostname}'."
),
),
),
Card(
Details(
Summary(
Grid(
DivHStacked(
UkIcon(icon="play"),
AdminButton(
"Import from file",
hx_post="/eosdash/admin",
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='js:{ "category": "configuration", "action": "import_from_file", "import_file_name": document.querySelector("[name=\'selected_import_file_name\']").value }',
),
Select(
*Options(*import_from_file_names),
id="import_file_name",
name="selected_import_file_name", # Name of hidden input field with selected value
placeholder="Select file",
),
),
import_from_file_status,
),
cls="list-none",
),
P(f"Import configuration from config file on '{eosdash_hostname}'."),
),
),
],
)
def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) -> Div:
"""Generates the administrative dashboard layout.
This includes configuration management and other administrative tools.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
data (Optional[dict], optional): Incoming data to trigger admin actions. Defaults to None.
Returns:
Div: A `Div` component containing the assembled admin interface.
"""
# Get current configuration from server
server = f"http://{eos_host}:{eos_port}"
try:
result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status()
config = result.json()
except requests.exceptions.HTTPError as e:
config = {}
detail = result.json()["detail"]
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
logger.warning(warning_msg)
return Error(warning_msg)
except Exception as e:
warning_msg = f"Can not retrieve configuration from {server}: {e}"
logger.warning(warning_msg)
return Error(warning_msg)
rows = []
last_category = ""
for category, admin in [
AdminConfig(eos_host, eos_port, data, config),
]:
if category != last_category:
rows.append(H3(category))
rows.append(DividerLine())
last_category = category
if isinstance(admin, list):
for card in admin:
rows.append(card)
else:
rows.append(admin)
return Div(*rows, cls="space-y-4")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More