6 Commits

Author SHA1 Message Date
Normann
ecaf1366ba fix 2025-02-16 23:01:39 +01:00
Normann
027ba7c07f [attr-defined,unused-ignore] usage 2025-02-16 22:58:00 +01:00
NormannK
f1d8728da4 . 2025-02-16 16:22:37 +01:00
NormannK
12df87d392 type: ignore changes 2025-02-16 16:17:44 +01:00
NormannK
1d6b31ceb4 pre-commit run changes 2025-02-16 15:35:17 +01:00
NormannK
e7143f6540 pre-commit autoupdate 2025-02-16 15:21:41 +01:00
216 changed files with 21913 additions and 42013 deletions

View File

@@ -8,20 +8,18 @@ body:
- type: markdown
attributes:
value: >
Please post your idea first as a [Discussion](https://github.com/Akkudoktor-EOS/EOS/discussions)
to validate it and bring attention to it. After validation,
you can open this issue for a more technical developer discussion.
Check the [Contributor Guide](https://github.com/Akkudoktor-EOS/EOS/blob/main/CONTRIBUTING.md)
if you need more information.
- type: textarea
attributes:
label: "Describe the enhancement or feature request:"
validations:
required: true
- type: textarea
attributes:
label: "Link to discussion and related issues"
description: >
<link here>
render: python
validations:
required: false
@@ -30,5 +28,6 @@ body:
label: "Proposed implementation"
description: >
How it could be implemented with a high level API.
render: python
validations:
required: false

View File

@@ -1,99 +0,0 @@
name: Bump Version
# Trigger the workflow on any push to main
on:
push:
branches:
- main
jobs:
bump-version:
runs-on: ubuntu-latest
name: Bump Version Workflow
steps:
# --- Step 1: Checkout the repository ---
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to create tags and see full history
persist-credentials: true # Needed for pushing commits and tags
# --- Step 2: Set up Python ---
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
# --- Step 3: Calculate version dynamically ---
- name: Calculate version
id: calc
run: |
# Call custom version calculation script
VERSION=$(python scripts/get_version.py)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Computed version: $VERSION"
# --- Step 4: Skip workflow for development versions ---
- name: Skip if version contains 'dev'
run: |
# Exit workflow early if the version contains 'dev'
if [[ "${{ steps.calc.outputs.version }}" == *dev* ]]; then
echo "Version contains 'dev', skipping bump version workflow."
exit 0
fi
# --- Step 5: Update files and commit if necessary ---
- name: Update files and commit
run: |
# Define files to update
UPDATE_FILES="haaddon/config.yaml"
# Call general Python version replacement script
python scripts/update_version.py "${{ steps.calc.outputs.version }}" $UPDATE_FILES
# Commit changes if any
git config user.name "github-actions"
git config user.email "actions@github.com"
git add $UPDATE_FILES
if git diff --cached --quiet; then
echo "No files changed. Skipping commit."
else
git commit -m "chore: bump version to ${{ steps.calc.outputs.version }}"
git push
# --- Step 6: Create release tag ---
- name: Create release tag if it does not exist
id: tagging
run: |
TAG="v${{ steps.calc.outputs.version }}"
if git rev-parse --verify "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists. Skipping tag creation."
echo "created=false" >> $GITHUB_OUTPUT
else
git tag -a "v${{ steps.calc.outputs.version }}" -m "Release ${{ steps.calc.outputs.version }}"
git push origin "v${{ steps.calc.outputs.version }}"
echo "created=true" >> $GITHUB_OUTPUT
fi
# --- Step 7: Bump to development version ---
- name: Bump dev version
id: bump_dev
run: |
VERSION_BASE=$(python scripts/bump_dev_version.py | tail -n1)
if [ -z "$VERSION_BASE" ]; then
echo "Error: bump_dev_version.py returned an empty version."
exit 1
fi
echo "version_base=$VERSION_BASE" >> $GITHUB_OUTPUT
git config user.name "github-actions"
git config user.email "actions@github.com"
git add src/akkudoktoreos/core/version.py
if git diff --cached --quiet; then
echo "version.py not changed. Skipping commit."
else
git commit -m "chore: bump dev version to ${VERSION_BASE}"
git push

View File

@@ -195,7 +195,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

@@ -16,7 +16,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13.9"
python-version: "3.12"
- name: Install dependencies
run: |
@@ -26,7 +26,7 @@ jobs:
- name: Run Pytest
run: |
pip install -e .
python -m pytest --finalize --check-config-side-effect -vs --cov src --cov-report term-missing
python -m pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing
- name: Upload test artifacts
uses: actions/upload-artifact@v4

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

@@ -1,9 +1,8 @@
# Exclude some file types from automatic code style
exclude: \.(json|csv)$
repos:
# --- Basic sanity checks ---
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: check-merge-conflict
- id: check-toml
@@ -11,71 +10,26 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-merge-conflict
exclude: '\.rst$' # Exclude .rst files from whitespace cleanup
# --- Import sorting ---
exclude: '\.rst$' # Exclude .rst files
- repo: https://github.com/PyCQA/isort
rev: 7.0.0
rev: 6.0.0
hooks:
- id: isort
# --- Linting + Formatting via Ruff ---
name: isort
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.1
rev: v0.9.6
hooks:
# Run the linter and fix simple isssues automatically
# Run the linter and fix simple issues automatically
- id: ruff
args: [--fix]
# Run the formatter
# Run the formatter.
- id: ruff-format
# --- Static type checking ---
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.18.2
rev: 'v1.15.0'
hooks:
- id: mypy
additional_dependencies:
- types-requests==2.32.4.20250913
- pandas-stubs==2.3.2.250926
- tokenize-rt==6.2.0
- types-docutils==0.22.2.20251006
- types-PyYaml==6.0.12.20250915
pass_filenames: false
# --- Markdown linter ---
- repo: https://github.com/jackdewinter/pymarkdown
rev: v0.9.32
hooks:
- id: pymarkdown
files: ^docs/
args:
- --config=docs/pymarkdown.json
- scan
# --- Commit message linting ---
# - Local cross-platform hooks
- repo: local
hooks:
# Validate commit messages (using Python wrapper)
- id: commitizen-commit
name: Commitizen (venv-aware)
entry: python3 scripts/cz_check_commit_message.py
language: system
stages: [commit-msg]
pass_filenames: false
# Branch name check on push (using Python wrapper)
- id: commitizen-branch
name: Commitizen branch check
entry: python3 scripts/cz_check_branch.py
language: system
stages: [pre-push]
pass_filenames: false
# Validate new commit messages before push (using Python wrapper)
- id: commitizen-new-commits
name: Commitizen (check new commits only, .venv aware)
entry: python3 -m scripts.cz_check_new_commits
language: system
stages: [pre-push]
- "types-requests==2.32.0.20241016"
- "pandas-stubs==2.2.3.241009"
- "numpy==2.1.3"
pass_filenames: false

View File

@@ -1,277 +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.2.0 (2025-11-09)
The most important new feature is **automatic optimization**.
EOS can now independently perform optimization at regular intervals.
This is based on the configured system parameters and forecasts, and also uses supplied
measurement data, such as the current battery SoC.
The result is an energy-management plan as well as the optimization output.
The existing optimization interface using `POST /optimize` remains available and can still
be used as before.
In addition, bugs were fixed and new features were added:
- Automatic optimization creates a **default configuration** if none is provided.
This is intended to make it easier to create a custom configuration by adapting the default.
- The parameters of the genetic optimization algorithm (number of generations, etc.) are now
configurable.
- For home appliances, start windows can now be specified (experimental).
- Configuration files from previous versions are converted to the current format on first launch.
- There are now measurement keys that are permanently assigned to a specific device simulation.
This simplifies providing measurement values for device simulations (e.g. battery SoC).
- The infrastructure and first applications for **feed-in tariff forecasting**
(currently only fixed tariffs) are now integrated.
- EOSdash has been expanded with new tabs for displaying the **energy-management plan**
and **predictions**.
- The documentation has been updated and expanded in many places.
### Feat
- Energy-management plan generation based on S2 standard instructions
- Feed-in-tariff prediction support (incl. tests & docs)
- `LoadAkkudoktorAdjusted` load prediction variant
- Standardized measurement keys for battery/EV SoC
- Measurement keys configurable via EOS configuration
- Setup default device configuration for automatic optimization
- Health endpoints show version + last optimization timestamps
- Configuration of genetic algorithm parameters
- Configuration options for home-appliance time windows
- Mitigation of legacy configuration
- Config backup enhancements:
- Timestamp-based backup IDs
- API to list backups
- API to revert to a specific backup
- EOSdash Admin tab integration
- Pendulum date types via `pydantic_extra_types.pendulum_dt`
- `Time`, `TimeWindow`, `TimeWindowSequence`, and `to_time` helpers in `datetimeutil`
- Extended `DataRecord` with configurable field-like semantics
- EOSdash: Solution view now displays genetic optimization results and aggregated totals
- EOSdash UI:
- Plan tab
- Predictions tab
- Cache management in Admin tab
- About tab
- Pydantic merge model tests
- Developer profiling entry in Makefile
- Changelog & docs updated for commitizen release flow
- Developer documentation updated
- Improved install & development documentation
### Changed
- Battery simulation
- Performance improvements
- Charge + start times now reflect realistic simulation
- Appliance simulation:
- Time windows may roll over to next day
- Revised load prediction by splitting original `LoadAkkudoktor` into:
- `LoadAkkudoktor`
- `LoadAkkudoktorAdjusted`
### Fixed
- Correct URL/path for Akkudoktor forum in README
- Automatic optimization:
- Reuses previous start solution
- Interval execution + locking + new endpoints
- Properly loads required data
- EV charge-rate migration for proper availability
- Genetic common settings consistently available
- Config markdown generation
- Recognize environment variables on EOS server startup
- Remove `0.0.0.0 → localhost` translation on Windows
- Allow hostnames as well as IPs
- Access Pydantic model fields via class instead of instance
- Down-sampling in `key_to_array`
- `/v1/admin/cache/clear` clears all cache files; added `/clear-expired`
- Use `tzfpy` instead of timezonefinder for more accurate EU timezones
- Explicit provider settings in config instead of union
- ClearOutside weather prediction irradiance calculation
- Test config file priority without `config_eos` fixture
- Complete optimization sample-request documentation
- Replace gitlint with commitizen
- Synchronize pre-commit config with real dependencies
- Add missing `babel` to requirements
- Fix documentation, tests, and implementation around optimization + predictions
### Chore
- Use memory cache for inverter interpolation
- Refactor genetic modules (split config, remove device singleton)
- Rename memory cache to `CacheEnergyManagementStore`
- Use class properties for config/EMS/prediction mixins
- Skip matplotlib debug logs
- Auto-sync Bokeh JS CDN version
- Rename `hello.py``about.py` in EOSdash
- Remove EOSdash demo page
- Split server test from system test
- Move doc utils to `generate_config_md.py`
- Improve documentation for pydantic merge models
- Remove pendulum warning from README
- Drop GitHub Discussions from contributing docs
- Rename or reorganize files / classes during refactors
### BREAKING CHANGES
EOS configuration + v1 API have changed:
- `available_charge_rates_percent` removed → replaced by `charge_rate`
- Optimization parameter `hours` → renamed to `horizon_hours`
- Device config must explicitly list devices + properties
- Prediction providers now explicit (instead of union)
- Measurement keys provided as lists
- Feed-in-tariff providers must be explicitly configured
- `/v1/measurement/loadxxx` endpoints removed → use generic measurement endpoints
- `/v1/admin/cache/clear` now clears **all*- cache files;
`/v1/admin/cache/clear-expired` only clears expired entries
## v0.1.0 (2025-09-30)
### Feat
- added Changelog for 0.0.0 and 0.1.0
## v0.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.
### Feat
#### 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
### Refactor
- 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
### Fix
- 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
### Build
- Python version requirement updated to 3.10+
- added Bandit security checks
- 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
### Notes
#### 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.

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
@@ -14,20 +14,20 @@ Please report flaws or vulnerabilities in the [GitHub Issue Tracker](https://git
## Ideas & Features
Issues in the [GitHub Issue Tracker](https://github.com/Akkudoktor-EOS/EOS/issues) are also fine
to discuss ideas and features.
Please first discuss the idea in a [GitHub Discussion](https://github.com/Akkudoktor-EOS/EOS/discussions) or the [Akkudoktor Forum](https://www.akkudoktor.net/forum/diy-energie-optimierungssystem-opensource-projekt/) before opening an issue.
You may first discuss the idea in the [Akkudoktor Forum](https://www.akkudoktor.net/forum/diy-energie-optimierungssystem-opensource-projekt/) before opening an issue.
There are just too many possibilities and the project would drown in tickets otherwise.
## 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
Setup virtual environment, then activate virtual environment and install development dependencies.
See also [README.md](README.md).
```bash
python -m venv .venv
@@ -60,7 +60,6 @@ To run formatting automatically before every commit:
```bash
pre-commit install
pre-commit install --hook-type commit-msg --hook-type pre-push
```
Or run them manually:
@@ -76,18 +75,3 @@ Use `pytest` to run tests locally:
```bash
python -m pytest -vs --cov src --cov-report term-missing tests/
```
### Commit message style
Our commit message checks use
[`commitizen`](https://commitizen-tools.github.io/commitizen/#pre-commit-integration). The checks
enforce the [`Conventional Commits`](https://www.conventionalcommits.org) commit message style.
You may use [`commitizen`](https://commitizen-tools.github.io/commitizen) also to create a
commit message and commit your change.
## Thank you!
And last but not least thanks to all our contributors
[![Contributors](https://contrib.rocks/image?repo=Akkudoktor-EOS/EOS)](https://github.com/Akkudoktor-EOS/EOS/graphs/contributors)

View File

@@ -1,8 +1,4 @@
# syntax=docker/dockerfile:1.7
# Dockerfile
# Set base image first
ARG PYTHON_VERSION=3.13.9
ARG PYTHON_VERSION=3.12.7
FROM python:${PYTHON_VERSION}-slim
LABEL source="https://github.com/Akkudoktor-EOS/EOS"
@@ -13,16 +9,6 @@ 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
# Set environment variables to reduce threading needs
ENV OPENBLAS_NUM_THREADS=1
ENV OMP_NUM_THREADS=1
ENV MKL_NUM_THREADS=1
ENV PIP_PROGRESS_BAR=off
ENV PIP_NO_COLOR=1
WORKDIR ${EOS_DIR}
RUN adduser --system --group --no-create-home eos \
@@ -35,25 +21,15 @@ RUN adduser --system --group --no-create-home eos \
&& mkdir -p "${EOS_CONFIG_DIR}" \
&& chown eos "${EOS_CONFIG_DIR}"
# Install requirements
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
pip install -r requirements.txt
# Copy source
COPY src/ ./src
COPY pyproject.toml .
RUN mkdir -p src && pip install -e .
# Create version information
COPY scripts/get_version.py ./scripts/get_version.py
RUN python scripts/get_version.py > ./version.txt
RUN rm ./scripts/get_version.py
RUN echo "Building Akkudoktor-EOS with Python $PYTHON_VERSION"
# Install akkudoktoreos package in editable form (-e)
# pyproject-toml will read the version from version.txt
RUN pip install --no-cache-dir -e .
COPY src src
USER eos
ENTRYPOINT []
@@ -61,7 +37,6 @@ ENTRYPOINT []
EXPOSE 8503
EXPOSE 8504
# Ensure EOS and EOSdash bind to 0.0.0.0
CMD ["python", "-m", "akkudoktoreos.server.eos", "--host", "0.0.0.0"]
CMD ["python", "src/akkudoktoreos/server/eos.py", "--host", "0.0.0.0"]
VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"]

View File

@@ -1,8 +1,5 @@
# Define the targets
.PHONY: help venv pip install dist test test-full test-system test-ci test-profile docker-run docker-build docs read-docs clean format gitlint mypy run run-dev run-dash run-dash-dev prepare-version test-version
# - Take VERSION from version.py
VERSION := $(shell python3 scripts/get_version.py)
.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
@@ -14,33 +11,25 @@ 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 " 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 all tests (e.g. to finalize a commit)."
@echo " test-system - Run tests with system tests enabled."
@echo " test-ci - Run tests as CI does. No user config file allowed."
@echo " test-profile - Run single test optimization with profiling."
@echo " dist - Create distribution (in dist/)."
@echo " clean - Remove generated documentation, distribution and virtual environment."
@echo " prepare-version - Prepare a version defined in setup.py."
# Target to set up a Python 3 virtual environment
venv:
python3 -m venv .venv
@PYVER=$$(./.venv/bin/python --version) && \
echo "Virtual environment created in '.venv' with $$PYVER. Activate it using 'source .venv/bin/activate'."
@echo "Virtual environment created in '.venv'. Activate it using 'source .venv/bin/activate'."
# Target to install dependencies from requirements.txt
pip: venv
@@ -53,12 +42,8 @@ pip-dev: pip
.venv/bin/pip install -r requirements-dev.txt
@echo "Dependencies installed from requirements-dev.txt."
# Target to create a version.txt
version-txt:
echo "$(VERSION)" > version.txt
# Target to install EOS in editable form (development mode) into virtual environment.
install: pip-dev version-txt
install: pip
.venv/bin/pip install build
.venv/bin/pip install -e .
@echo "EOS installed in editable form (development mode)."
@@ -70,7 +55,7 @@ dist: pip
@echo "Distribution created (see dist/)."
# Target to generate documentation
gen-docs: pip-dev version-txt
gen-docs: pip-dev
.venv/bin/pip install -e .
.venv/bin/python ./scripts/generate_config_md.py --output-file docs/_generated/config.md
.venv/bin/python ./scripts/generate_openapi_md.py --output-file docs/_generated/openapi.md
@@ -79,20 +64,14 @@ gen-docs: pip-dev version-txt
# Target to build HTML documentation
docs: pip-dev
.venv/bin/pytest --full-run tests/test_docsphinx.py
.venv/bin/sphinx-build -M html docs build/docs
@echo "Documentation build to build/docs/html/."
# Target to read the HTML documentation
read-docs:
read-docs: docs
@echo "Read the documentation in your browser"
.venv/bin/pytest --full-run tests/test_docsphinx.py
.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..."
@@ -112,7 +91,7 @@ run:
run-dev:
@echo "Starting EOS development server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eos --host localhost --port 8503 --log_level DEBUG --startup_eosdash false --reload true
.venv/bin/python -m akkudoktoreos.server.eos --host localhost --port 8503 --reload true
run-dash:
@echo "Starting EOSdash production server, please wait..."
@@ -120,7 +99,7 @@ run-dash:
run-dash-dev:
@echo "Starting EOSdash development server, please wait..."
.venv/bin/python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --log_level DEBUG --reload true
.venv/bin/python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --reload true
# Target to setup tests.
test-setup: pip-dev
@@ -131,60 +110,22 @@ 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 --finalize --check-config-side-effect -vs --cov src --cov-report term-missing
# Target to run tests including the system tests.
test-system:
@echo "Running tests incl. system tests..."
.venv/bin/pytest --system-test -vs --cov src --cov-report term-missing
# Target to run all tests.
test-full:
@echo "Running all tests..."
.venv/bin/pytest --finalize
# Target to run tests including the single test optimization with profiling.
test-profile:
@echo "Running single test optimization with profiling..."
.venv/bin/python tests/single_test_optimization.py --profile
.venv/bin/pytest --full-run
# Target to format code.
format:
.venv/bin/pre-commit run --all-files
# Target to trigger gitlint using pre-commit for the latest commit messages
gitlint:
.venv/bin/cz check --rev-range main..HEAD
# Target to format code.
mypy:
.venv/bin/mypy
# Run entire setup on docker
docker-run:
@docker pull python:3.13.9-slim
@docker compose up --remove-orphans
docker-build:
@docker pull python:3.13.9-slim
@docker compose build
# Propagete version info to all version files
# Take UPDATE_FILES from GitHub action bump-version.yml
UPDATE_FILES := $(shell sed -n 's/^[[:space:]]*UPDATE_FILES[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \
.github/workflows/bump-version.yml)
prepare-version: #pip-dev
@echo "Update version to $(VERSION) from version.py in files $(UPDATE_FILES) and doc"
.venv/bin/python ./scripts/update_version.py $(VERSION) $(UPDATE_FILES)
.venv/bin/python ./scripts/convert_lightweight_tags.py
.venv/bin/python ./scripts/generate_config_md.py --output-file docs/_generated/config.md
.venv/bin/python ./scripts/generate_openapi_md.py --output-file docs/_generated/openapi.md
.venv/bin/python ./scripts/generate_openapi.py --output-file openapi.json
.venv/bin/pytest -vv --finalize tests/test_version.py
test-version:
echo "Test version information to be correctly set in all version files"
.venv/bin/pytest -vv tests/test_version.py
@docker compose build --pull

174
README.md
View File

@@ -1,158 +1,106 @@
![AkkudoktorEOS](./docs/_static/logo.png#gh-light-mode-only)
![AkkudoktorEOS](./docs/_static/logo_dark.png#gh-dark-mode-only)
# Energy System Simulation and Optimization
**Build optimized energy management plans for your home automation**
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.
AkkudoktorEOS is a comprehensive solution for simulating and optimizing energy systems based on
renewable sources. Optimize your photovoltaic systems, battery storage, load management, and
electric vehicles while considering real-time electricity pricing.
Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/).
## Why use AkkudoktorEOS?
## Getting Involved
AkkudoktorEOS can be used to build energy management plans that are optimized for your specific
setup of PV system, battery, electric vehicle, household load and electricity pricing. It can
be integrated into home automation systems such as NodeRED, Home Assistant, EVCC.
## 🏘️ Community
We are an open-source community-driven project and we love to hear from you. Here are some ways to
get involved:
- [GitHub Issue Tracker](https://github.com/Akkudoktor-EOS/EOS/issues): discuss ideas and features,
and report bugs.
- [Akkudoktor Forum](https://www.akkudoktor.net/c/der-akkudoktor/eos): get direct suppport from the
cummunity.
## What do people build with AkkudoktorEOS
The community uses AkkudoktorEOS to minimize grid energy consumption and to maximize the revenue
from grid energy feed in with their home automation system.
- Andreas Schmitz, [the Akkudoktor](https://www.youtube.com/@Akkudoktor), uses
EOS integrated in his NodeRED home automation system for
[OpenSource Energieoptimierung](https://www.youtube.com/watch?v=sHtv0JCxAYk).
- Jörg, [meintechblog](https://www.youtube.com/@meintechblog), uses EOS for
day-ahead optimization for time-variable energy prices. See:
[So installiere ich EOS von Andreas Schmitz](https://www.youtube.com/watch?v=9XCPNU9UqSs)
## Why not use AkkudoktorEOS?
AkkudoktorEOS does not control your home automation assets. It must be integrated into a home
automation system. If you do not use a home automation system or you feel uncomfortable with
the configuration effort needed for the integration you should better use other solutions.
## Quick Start
Run EOS with Docker (access dashboard at `http://localhost:8504`):
```bash
docker run -d \
--name akkudoktoreos \
-p 8503:8503 \
-p 8504:8504 \
-e OPENBLAS_NUM_THREADS=1 \
-e OMP_NUM_THREADS=1 \
-e MKL_NUM_THREADS=1 \
-e EOS_SERVER__HOST=0.0.0.0 \
-e EOS_SERVER__EOSDASH_HOST=0.0.0.0 \
-e EOS_SERVER__EOSDASH_PORT=8504 \
--ulimit nproc=65535:65535 \
--ulimit nofile=65535:65535 \
--security-opt seccomp=unconfined \
akkudoktor/eos:latest
```
## System Requirements
- **Python**: 3.11 or higher
- **Architecture**: amd64, aarch64 (armv8)
- **OS**: Linux, Windows, macOS
> **Note**: Other architectures (armv6, armv7) require manual compilation of dependencies with Rust and GCC.
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Installation
### Docker (Recommended)
The project requires Python 3.10 or newer. Official docker images can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos).
```bash
docker pull akkudoktor/eos:latest
docker compose up -d
```
Following sections describe how to locally start the EOS server on `http://localhost:8503`.
Access the API at `http://localhost:8503` (docs at `http://localhost:8503/docs`)
### Run from source
### From Source
Install dependencies in virtual environment:
```bash
git clone https://github.com/Akkudoktor-EOS/EOS.git
cd EOS
```
**Linux:**
Linux:
```bash
python -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e .
.venv/bin/python -m akkudoktoreos.server.eos
```
**Windows:**
Windows:
```cmd
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pip install -e .
.venv\Scripts\python -m akkudoktoreos.server.eos
```
Finally, start the EOS server:
Linux:
```bash
.venv/bin/python src/akkudoktoreos/server/eos.py
```
Windows:
```cmd
.venv\Scripts\python src/akkudoktoreos/server/eos.py
```
### Docker
```bash
docker compose up
```
If you are running the EOS container on a system hosting multiple services, such as a Synology NAS, and want to allow external network access to EOS, please ensure that the default exported ports (8503, 8504) are available on the host. On Synology systems, these ports might already be in use (refer to [this guide](https://kb.synology.com/en-me/DSM/tutorial/What_network_ports_are_used_by_Synology_services)). If the ports are occupied, you will need to reconfigure the exported ports accordingly.
## Configuration
EOS uses `EOS.config.json` for configuration. If the file doesn't exist, a default configuration is
created automatically.
This project uses the `EOS.config.json` file to manage configuration settings.
### Custom Configuration Directory
### Default Configuration
```bash
export EOS_DIR=/path/to/your/config
```
A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values.
### Configuration Methods
### Custom Configuration
1. **EOSdash** (Recommended) - Web interface at `http://localhost:8504`
2. **Manual** - Edit `EOS.config.json` directly
3. **API** - Use the [Server API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json)
Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`.
See the [documentation](https://akkudoktor-eos.readthedocs.io/) for all configuration options.
- If the directory specified by `EOS_DIR` contains an existing `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`.
## Port Configuration
### Configuration Updates
**Default ports**: 8503 (API), 8504 (Dashboard)
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 running on shared systems (e.g., Synology NAS), these ports may conflict with system services. Reconfigure port mappings as needed:
## Classes and Functionalities
```bash
docker run -p 8505:8503 -p 8506:8504 ...
```
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:
## API Documentation
- `Battery`: Simulates a battery storage system, including capacity, state of charge, and now charge and discharge losses.
Interactive API docs available at:
- Swagger UI: `http://localhost:8503/docs`
- OpenAPI Spec: [View Online](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json)
- `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and historical generation data.
## Resources
- `Load`: Models the load requirements of a household or business, enabling the prediction of future energy demand.
- [Full Documentation](https://akkudoktor-eos.readthedocs.io/)
- [Installation Guide (German)](https://www.youtube.com/watch?v=9XCPNU9UqSs)
- `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various operating conditions.
## Contributing
- `Strompreis`: Provides information on electricity prices, enabling optimization of energy consumption and generation based on tariff information.
We welcome contributions! See [CONTRIBUTING](CONTRIBUTING.md) for guidelines.
- `EMS`: The Energy Management System (EMS) coordinates the interaction between the various components, performs optimization, and simulates the operation of the entire energy system.
[![Contributors](https://contrib.rocks/image?repo=Akkudoktor-EOS/EOS)](https://github.com/Akkudoktor-EOS/EOS/graphs/contributors)
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.
## License
### Customization and Extension
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
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.
## Server API
See the Swagger API documentation for detailed information: [EOS OpenAPI Spec](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json)
## Further resources
- [Installation guide (de)](https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/)

View File

@@ -1,12 +1,10 @@
---
networks:
default:
external: true
name: "eos"
services:
eos:
image: "akkudoktor/eos:${EOS_VERSION}"
container_name: "akkudoktoreos"
read_only: true
build:
context: .
@@ -16,31 +14,12 @@ services:
env_file:
- .env
environment:
- OPENBLAS_NUM_THREADS=1
- OMP_NUM_THREADS=1
- MKL_NUM_THREADS=1
- PIP_PROGRESS_BAR=off
- PIP_NO_COLOR=1
- EOS_CONFIG_DIR=config
- EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
- EOS_SERVER__HOST=0.0.0.0
- EOS_SERVER__PORT=8503
- EOS_SERVER__EOSDASH_HOST=0.0.0.0
- EOS_SERVER__EOSDASH_PORT=8504
ulimits:
nproc: 65535
nofile: 65535
security_opt:
- seccomp:unconfined
restart: unless-stopped
- EOS_PREDICTION__LATITUDE=52.2
- EOS_PREDICTION__LONGITUDE=13.4
- EOS_ELECPRICE__PROVIDER=ElecPriceAkkudoktor
- EOS_ELECPRICE__CHARGES_KWH=0.21
ports:
# Configure what ports to expose on host
- "${EOS_SERVER__PORT}:8503"
- "${EOS_SERVER__EOSDASH_PORT}:8504"
# Volume mount configuration (optional)
# 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}"

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
## Cache Configuration
<!-- pyml disable line-length -->
:::{table} cache
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| cleanup_interval | `EOS_CACHE__CLEANUP_INTERVAL` | `float` | `rw` | `300` | Intervall in seconds for EOS file cache cleanup. |
| subpath | `EOS_CACHE__SUBPATH` | `Optional[pathlib.Path]` | `rw` | `cache` | Sub-path for the EOS cache data directory. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"cache": {
"subpath": "cache",
"cleanup_interval": 300.0
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,405 +0,0 @@
## Base configuration for devices simulation settings
<!-- pyml disable line-length -->
:::{table} devices
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| batteries | `EOS_DEVICES__BATTERIES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of battery devices |
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of electric vehicle devices |
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `Optional[list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings]]` | `rw` | `None` | List of home appliances |
| inverters | `EOS_DEVICES__INVERTERS` | `Optional[list[akkudoktoreos.devices.devices.InverterCommonSettings]]` | `rw` | `None` | List of inverters |
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries that can be set |
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of electric vehicles that can be set |
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home_appliances that can be set |
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters that can be set |
| measurement_keys | | `Optional[list[str]]` | `ro` | `N/A` | None |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": "[0. 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]",
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
],
"max_batteries": 1,
"electric_vehicles": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": "[0. 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]",
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
],
"max_electric_vehicles": 1,
"inverters": [],
"max_inverters": 1,
"home_appliances": [],
"max_home_appliances": 1
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": "[0. 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]",
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
],
"max_batteries": 1,
"electric_vehicles": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": "[0. 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]",
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
],
"max_electric_vehicles": 1,
"inverters": [],
"max_inverters": 1,
"home_appliances": [],
"max_home_appliances": 1,
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w",
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
}
```
<!-- pyml enable line-length -->
### Inverter devices base settings
<!-- pyml disable line-length -->
:::{table} devices::inverters::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| battery_id | `Optional[str]` | `rw` | `None` | ID of battery controlled by this inverter. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| max_power_w | `Optional[float]` | `rw` | `None` | Maximum power [W]. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | None |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"inverters": [
{
"device_id": "battery1",
"max_power_w": 10000.0,
"battery_id": null
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"inverters": [
{
"device_id": "battery1",
"max_power_w": 10000.0,
"battery_id": null,
"measurement_keys": []
}
]
}
}
```
<!-- pyml enable line-length -->
### Home Appliance devices base settings
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | None |
| time_windows | `Optional[akkudoktoreos.utils.datetimeutil.TimeWindowSequence]` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"device_id": "battery1",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
"windows": [
{
"start_time": "10:00:00.000000 Europe/Berlin",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
}
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"device_id": "battery1",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
"windows": [
{
"start_time": "10:00:00.000000 Europe/Berlin",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
},
"measurement_keys": []
}
]
}
}
```
<!-- pyml enable line-length -->
### Battery devices base settings
<!-- pyml disable line-length -->
:::{table} devices::batteries::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| capacity_wh | `int` | `rw` | `8000` | Capacity [Wh]. |
| charge_rates | `Optional[numpydantic.vendor.npbase_meta_classes.NDArray]` | `rw` | `[0. 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]` | Charge rates as factor of maximum charging power [0.00 ... 1.00]. None triggers fallback to default charge-rates. |
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [€/kWh]. |
| max_charge_power_w | `Optional[float]` | `rw` | `5000` | Maximum charging power [W]. |
| max_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
| measurement_key_power_3_phase_sym_w | `str` | `ro` | `N/A` | None |
| measurement_key_power_l1_w | `str` | `ro` | `N/A` | None |
| measurement_key_power_l2_w | `str` | `ro` | `N/A` | None |
| measurement_key_power_l3_w | `str` | `ro` | `N/A` | None |
| measurement_key_soc_factor | `str` | `ro` | `N/A` | None |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | None |
| min_charge_power_w | `Optional[float]` | `rw` | `50` | Minimum charging power [W]. |
| min_soc_percentage | `int` | `rw` | `0` | Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000.0,
"min_charge_power_w": 50.0,
"charge_rates": "[0. 0.25 0.5 0.75 1. ]",
"min_soc_percentage": 10,
"max_soc_percentage": 100
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000.0,
"min_charge_power_w": 50.0,
"charge_rates": "[0. 0.25 0.5 0.75 1. ]",
"min_soc_percentage": 10,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
]
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,99 +0,0 @@
## Electricity Price Prediction Configuration
<!-- pyml disable line-length -->
:::{table} elecprice
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `Optional[float]` | `rw` | `None` | Electricity price charges [€/kWh]. Will be added to variable market price. |
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Import provider settings. |
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `Optional[float]` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"elecpriceimport": {
"import_file_path": null,
"import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for Energy Charts electricity price provider
<!-- pyml disable line-length -->
:::{table} elecprice::energycharts
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| bidding_zone | `<enum 'EnergyChartsBiddingZones'>` | `rw` | `EnergyChartsBiddingZones.DE_LU` | Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI' |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"energycharts": {
"bidding_zone": "AT"
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for elecprice data import from file or JSON String
<!-- pyml disable line-length -->
:::{table} elecprice::elecpriceimport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import elecprice data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of electricity price forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"elecpriceimport": {
"import_file_path": null,
"import_json": "{\"elecprice_marketprice_wh\": [0.0003384, 0.0003318, 0.0003284]}"
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,30 +0,0 @@
## Energy Management Configuration
<!-- pyml disable line-length -->
:::{table} ems
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| interval | `EOS_EMS__INTERVAL` | `Optional[float]` | `rw` | `None` | Intervall in seconds between EOS energy management runs. |
| mode | `EOS_EMS__MODE` | `Optional[akkudoktoreos.core.emsettings.EnergyManagementMode]` | `rw` | `None` | Energy management mode [OPTIMIZATION | PREDICTION]. |
| startup_delay | `EOS_EMS__STARTUP_DELAY` | `float` | `rw` | `5` | Startup delay in seconds for EOS energy management runs. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"ems": {
"startup_delay": 5.0,
"interval": 300.0,
"mode": "OPTIMIZATION"
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,215 +0,0 @@
## Full example Config
<!-- pyml disable line-length -->
```json
{
"cache": {
"subpath": "cache",
"cleanup_interval": 300.0
},
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": "[0. 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]",
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
],
"max_batteries": 1,
"electric_vehicles": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": "[0. 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]",
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
],
"max_electric_vehicles": 1,
"inverters": [],
"max_inverters": 1,
"home_appliances": [],
"max_home_appliances": 1
},
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"elecpriceimport": {
"import_file_path": null,
"import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
}
},
"ems": {
"startup_delay": 5.0,
"interval": 300.0,
"mode": "OPTIMIZATION"
},
"feedintariff": {
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffImport": null
}
},
"general": {
"version": "0.2.0+dev.4dbc2d",
"data_folder_path": null,
"data_output_subpath": "output",
"latitude": 52.52,
"longitude": 13.405
},
"load": {
"provider": "LoadAkkudoktor",
"provider_settings": {
"LoadAkkudoktor": null,
"LoadVrm": null,
"LoadImport": null
}
},
"logging": {
"console_level": "TRACE",
"file_level": "TRACE"
},
"measurement": {
"load_emr_keys": [
"load0_emr"
],
"grid_export_emr_keys": [
"grid_export_emr"
],
"grid_import_emr_keys": [
"grid_import_emr"
],
"pv_production_emr_keys": [
"pv1_emr"
]
},
"optimization": {
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"genetic": {
"individuals": 400,
"generations": 400,
"seed": null,
"penalties": {
"ev_soc_miss": 10
}
}
},
"prediction": {
"hours": 48,
"historic_hours": 48
},
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
},
"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
}
],
"max_planes": 1
},
"server": {
"host": "127.0.0.1",
"port": 8503,
"verbose": false,
"startup_eosdash": true,
"eosdash_host": "127.0.0.1",
"eosdash_port": 8504
},
"utils": {},
"weather": {
"provider": "WeatherImport",
"provider_settings": {
"WeatherImport": null
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,126 +0,0 @@
## Feed In Tariff Prediction Configuration
<!-- pyml disable line-length -->
:::{table} feedintariff
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
| provider_settings | `EOS_FEEDINTARIFF__PROVIDER_SETTINGS` | `FeedInTariffCommonProviderSettings` | `rw` | `required` | Provider settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffImport": null
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for feed in tariff data import from file or JSON string
<!-- pyml disable line-length -->
:::{table} feedintariff::provider_settings::FeedInTariffImport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import feed in tariff data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of feed in tariff forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider_settings": {
"FeedInTariffImport": {
"import_file_path": null,
"import_json": "{\"fead_in_tariff_wh\": [0.000078, 0.000078, 0.000023]}"
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for elecprice fixed price
<!-- pyml disable line-length -->
:::{table} feedintariff::provider_settings::FeedInTariffFixed
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| feed_in_tariff_kwh | `Optional[float]` | `rw` | `None` | Electricity price feed in tariff [€/kWH]. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider_settings": {
"FeedInTariffFixed": {
"feed_in_tariff_kwh": 0.078
}
}
}
}
```
<!-- pyml enable line-length -->
### Feed In Tariff Prediction Provider Configuration
<!-- pyml disable line-length -->
:::{table} feedintariff::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| FeedInTariffFixed | `Optional[akkudoktoreos.prediction.feedintarifffixed.FeedInTariffFixedCommonSettings]` | `rw` | `None` | FeedInTariffFixed settings |
| FeedInTariffImport | `Optional[akkudoktoreos.prediction.feedintariffimport.FeedInTariffImportCommonSettings]` | `rw` | `None` | FeedInTariffImport settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffImport": null
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,73 +0,0 @@
## Settings for common configuration
General configuration to set directories of cache and output files and system location (latitude
and longitude).
Validators ensure each parameter is within a specified range. A computed property, `timezone`,
determines the time zone based on latitude and longitude.
Attributes:
latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.
longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.
Properties:
timezone (Optional[str]): Computed time zone string based on the specified latitude
and longitude.
<!-- pyml disable line-length -->
:::{table} general
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| config_file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | None |
| config_folder_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | None |
| data_folder_path | `EOS_GENERAL__DATA_FOLDER_PATH` | `Optional[pathlib.Path]` | `rw` | `None` | Path to EOS data directory. |
| data_output_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | None |
| data_output_subpath | `EOS_GENERAL__DATA_OUTPUT_SUBPATH` | `Optional[pathlib.Path]` | `rw` | `output` | Sub-path for the EOS output 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` | None |
| version | `EOS_GENERAL__VERSION` | `str` | `rw` | `0.2.0+dev.4dbc2d` | Configuration file version. Used to check compatibility. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"general": {
"version": "0.2.0+dev.4dbc2d",
"data_folder_path": null,
"data_output_subpath": "output",
"latitude": 52.52,
"longitude": 13.405
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"general": {
"version": "0.2.0+dev.4dbc2d",
"data_folder_path": null,
"data_output_subpath": "output",
"latitude": 52.52,
"longitude": 13.405,
"timezone": "Europe/Berlin",
"data_output_path": null,
"config_folder_path": "/home/user/.config/net.akkudoktoreos.net",
"config_file_path": "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,162 +0,0 @@
## Load Prediction Configuration
<!-- pyml disable line-length -->
:::{table} load
:widths: 10 20 10 5 5 30
:align: left
| 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` | `LoadCommonProviderSettings` | `rw` | `required` | Provider settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"provider": "LoadAkkudoktor",
"provider_settings": {
"LoadAkkudoktor": null,
"LoadVrm": null,
"LoadImport": null
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for load data import from file or JSON string
<!-- pyml disable line-length -->
:::{table} load::provider_settings::LoadImport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import load data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of load forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"provider_settings": {
"LoadImport": {
"import_file_path": null,
"import_json": "{\"load0_mean\": [676.71, 876.19, 527.13]}"
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for VRM API
<!-- pyml disable line-length -->
:::{table} load::provider_settings::LoadVrm
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| load_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
| load_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"provider_settings": {
"LoadVrm": {
"load_vrm_token": "your-token",
"load_vrm_idsite": 12345
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for load data import from file
<!-- pyml disable line-length -->
:::{table} load::provider_settings::LoadAkkudoktor
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| loadakkudoktor_year_energy_kwh | `Optional[float]` | `rw` | `None` | Yearly energy consumption (kWh). |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"provider_settings": {
"LoadAkkudoktor": {
"loadakkudoktor_year_energy_kwh": 40421.0
}
}
}
}
```
<!-- pyml enable line-length -->
### Load Prediction Provider Configuration
<!-- pyml disable line-length -->
:::{table} load::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| LoadAkkudoktor | `Optional[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings]` | `rw` | `None` | LoadAkkudoktor settings |
| LoadImport | `Optional[akkudoktoreos.prediction.loadimport.LoadImportCommonSettings]` | `rw` | `None` | LoadImport settings |
| LoadVrm | `Optional[akkudoktoreos.prediction.loadvrm.LoadVrmCommonSettings]` | `rw` | `None` | LoadVrm settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"provider_settings": {
"LoadAkkudoktor": null,
"LoadVrm": null,
"LoadImport": null
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,45 +0,0 @@
## Logging Configuration
<!-- pyml disable line-length -->
:::{table} logging
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| 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` | None |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"logging": {
"console_level": "TRACE",
"file_level": "TRACE"
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"logging": {
"console_level": "TRACE",
"file_level": "TRACE",
"file_path": "/home/user/.local/share/net.akkudoktor.eos/output/eos.log"
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,72 +0,0 @@
## Measurement Configuration
<!-- pyml disable line-length -->
:::{table} measurement
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| grid_export_emr_keys | `EOS_MEASUREMENT__GRID_EXPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy export to grid [kWh]. |
| grid_import_emr_keys | `EOS_MEASUREMENT__GRID_IMPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy import from grid [kWh]. |
| keys | | `list[str]` | `ro` | `N/A` | None |
| load_emr_keys | `EOS_MEASUREMENT__LOAD_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of a load [kWh]. |
| pv_production_emr_keys | `EOS_MEASUREMENT__PV_PRODUCTION_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are PV production energy meter readings [kWh]. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"measurement": {
"load_emr_keys": [
"load0_emr"
],
"grid_export_emr_keys": [
"grid_export_emr"
],
"grid_import_emr_keys": [
"grid_import_emr"
],
"pv_production_emr_keys": [
"pv1_emr"
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"measurement": {
"load_emr_keys": [
"load0_emr"
],
"grid_export_emr_keys": [
"grid_export_emr"
],
"grid_import_emr_keys": [
"grid_import_emr"
],
"pv_production_emr_keys": [
"pv1_emr"
],
"keys": [
"grid_export_emr",
"grid_import_emr",
"load0_emr",
"pv1_emr"
]
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,76 +0,0 @@
## General Optimization Configuration
<!-- pyml disable line-length -->
:::{table} optimization
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| algorithm | `EOS_OPTIMIZATION__ALGORITHM` | `Optional[str]` | `rw` | `GENETIC` | The optimization algorithm. |
| genetic | `EOS_OPTIMIZATION__GENETIC` | `Optional[akkudoktoreos.optimization.optimization.GeneticCommonSettings]` | `rw` | `None` | Genetic optimization algorithm configuration. |
| horizon_hours | `EOS_OPTIMIZATION__HORIZON_HOURS` | `Optional[int]` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
| interval | `EOS_OPTIMIZATION__INTERVAL` | `Optional[int]` | `rw` | `3600` | The optimization interval [sec]. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"optimization": {
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"genetic": {
"individuals": 400,
"generations": 400,
"seed": null,
"penalties": {
"ev_soc_miss": 10
}
}
}
}
```
<!-- pyml enable line-length -->
### General Genetic Optimization Algorithm Configuration
<!-- pyml disable line-length -->
:::{table} optimization::genetic
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| generations | `Optional[int]` | `rw` | `400` | Number of generations to evaluate the optimal solution [>= 10]. Defaults to 400. |
| individuals | `Optional[int]` | `rw` | `300` | Number of individuals (solutions) to generate for the (initial) generation [>= 10]. Defaults to 300. |
| penalties | `Optional[dict[str, Union[float, int, str]]]` | `rw` | `None` | A dictionary of penalty function parameters consisting of a penalty function parameter name and the associated value. |
| seed | `Optional[int]` | `rw` | `None` | Fixed seed for genetic algorithm. Defaults to 'None' which means random seed. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"optimization": {
"genetic": {
"individuals": 300,
"generations": 400,
"seed": null,
"penalties": {
"ev_soc_miss": 10
}
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,42 +0,0 @@
## General Prediction Configuration
This class provides configuration for prediction settings, allowing users to specify
parameters such as the forecast duration (in hours).
Validators ensure each parameter is within a specified range.
Attributes:
hours (Optional[int]): Number of hours into the future for predictions.
Must be non-negative.
historic_hours (Optional[int]): Number of hours into the past for historical data.
Must be non-negative.
Validators:
validate_hours (int): Ensures `hours` is a non-negative integer.
validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.
<!-- pyml disable line-length -->
:::{table} prediction
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| historic_hours | `EOS_PREDICTION__HISTORIC_HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the past for historical predictions data |
| hours | `EOS_PREDICTION__HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the future for predictions |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"prediction": {
"hours": 48,
"historic_hours": 48
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,340 +0,0 @@
## PV Forecast Configuration
<!-- pyml disable line-length -->
:::{table} pvforecast
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set |
| planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. |
| planes_azimuth | | `List[float]` | `ro` | `N/A` | None |
| planes_inverter_paco | | `Any` | `ro` | `N/A` | None |
| planes_peakpower | | `List[float]` | `ro` | `N/A` | None |
| planes_tilt | | `List[float]` | `ro` | `N/A` | None |
| planes_userhorizon | | `Any` | `ro` | `N/A` | None |
| provider | `EOS_PVFORECAST__PROVIDER` | `Optional[str]` | `rw` | `None` | PVForecast provider id of provider to be used. |
| provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `PVForecastCommonProviderSettings` | `rw` | `required` | Provider settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
},
"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
}
],
"max_planes": 1
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
},
"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
}
],
"max_planes": 1,
"planes_peakpower": [
5.0,
3.5
],
"planes_azimuth": [
180.0,
90.0
],
"planes_tilt": [
10.0,
20.0
],
"planes_userhorizon": [
[
10.0,
20.0,
30.0
],
[
5.0,
15.0,
25.0
]
],
"planes_inverter_paco": [
6000.0,
4000.0
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for VRM API
<!-- pyml disable line-length -->
:::{table} pvforecast::provider_settings::PVForecastVrm
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| pvforecast_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
| pvforecast_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastVrm": {
"pvforecast_vrm_token": "your-token",
"pvforecast_vrm_idsite": 12345
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for pvforecast data import from file or JSON string
<!-- pyml disable line-length -->
:::{table} pvforecast::provider_settings::PVForecastImport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import PV forecast data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of PV forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastImport": {
"import_file_path": null,
"import_json": "{\"pvforecast_ac_power\": [0, 8.05, 352.91]}"
}
}
}
}
```
<!-- pyml enable line-length -->
### PV Forecast Provider Configuration
<!-- pyml disable line-length -->
:::{table} pvforecast::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| PVForecastImport | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | PVForecastImport settings |
| PVForecastVrm | `Optional[akkudoktoreos.prediction.pvforecastvrm.PVForecastVrmCommonSettings]` | `rw` | `None` | PVForecastVrm settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
}
}
}
```
<!-- pyml enable line-length -->
### PV Forecast Plane Configuration
<!-- pyml disable line-length -->
:::{table} pvforecast::planes::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| albedo | `Optional[float]` | `rw` | `None` | Proportion of the light hitting the ground that it reflects back. |
| 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]. |
| loss | `Optional[float]` | `rw` | `14.0` | Sum of PV system losses in percent |
| module_model | `Optional[str]` | `rw` | `None` | Model of the PV modules of this plane. |
| modules_per_string | `Optional[int]` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
| mountingplace | `Optional[str]` | `rw` | `free` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
| 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. |
| 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'. |
| strings_per_inverter | `Optional[int]` | `rw` | `None` | Number of the strings of the inverter of this plane. |
| 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). |
| surface_tilt | `Optional[float]` | `rw` | `30.0` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
| 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. |
| userhorizon | `Optional[List[float]]` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```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
}
]
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,36 +0,0 @@
## Server Configuration
<!-- pyml disable line-length -->
:::{table} server
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `Optional[str]` | `rw` | `None` | EOSdash server IP address. Defaults to EOS server IP address. |
| eosdash_port | `EOS_SERVER__EOSDASH_PORT` | `Optional[int]` | `rw` | `None` | EOSdash server IP port number. Defaults to EOS server IP port number + 1. |
| host | `EOS_SERVER__HOST` | `Optional[str]` | `rw` | `127.0.0.1` | EOS server IP address. Defaults to 127.0.0.1. |
| port | `EOS_SERVER__PORT` | `Optional[int]` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. |
| startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `Optional[bool]` | `rw` | `True` | EOS server to start EOSdash server. Defaults to True. |
| verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"server": {
"host": "127.0.0.1",
"port": 8503,
"verbose": false,
"startup_eosdash": true,
"eosdash_host": "127.0.0.1",
"eosdash_port": 8504
}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,23 +0,0 @@
## Utils Configuration
<!-- pyml disable line-length -->
:::{table} utils
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"utils": {}
}
```
<!-- pyml enable line-length -->

View File

@@ -1,92 +0,0 @@
## Weather Forecast Configuration
<!-- pyml disable line-length -->
:::{table} weather
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| provider | `EOS_WEATHER__PROVIDER` | `Optional[str]` | `rw` | `None` | Weather provider id of provider to be used. |
| provider_settings | `EOS_WEATHER__PROVIDER_SETTINGS` | `WeatherCommonProviderSettings` | `rw` | `required` | Provider settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"weather": {
"provider": "WeatherImport",
"provider_settings": {
"WeatherImport": null
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for weather data import from file or JSON string
<!-- pyml disable line-length -->
:::{table} weather::provider_settings::WeatherImport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import weather data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of weather forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"weather": {
"provider_settings": {
"WeatherImport": {
"import_file_path": null,
"import_json": "{\"weather_temp_air\": [18.3, 17.8, 16.9]}"
}
}
}
}
```
<!-- pyml enable line-length -->
### Weather Forecast Provider Configuration
<!-- pyml disable line-length -->
:::{table} weather::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| WeatherImport | `Optional[akkudoktoreos.prediction.weatherimport.WeatherImportCommonSettings]` | `rw` | `None` | WeatherImport settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"weather": {
"provider_settings": {
"WeatherImport": null
}
}
}
```
<!-- pyml enable line-length -->

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 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

@@ -1,849 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
(configtimewindow-page)=
# Time Window Sequence Configuration
## Overview
The `TimeWindowSequence` model is used to configure allowed time slots for home appliance runs.
It contains a collection of `TimeWindow` objects that define when appliances can operate.
## Basic Structure
A `TimeWindowSequence` is configured as a JSON object with a `windows` array:
```json
{
"windows": [
{
"start_time": "09:00",
"duration": "PT2H",
"day_of_week": null,
"date": null,
"locale": null
}
]
}
```
## TimeWindow Fields
Each `TimeWindow` object has the following fields:
- **`start_time`** (required): Time when the window begins
- **`duration`** (required): How long the window lasts
- **`day_of_week`** (optional): Restrict to specific day of week
- **`date`** (optional): Restrict to specific calendar date
- **`locale`** (optional): Language for day name parsing
## Time Formats
### Start Time (`start_time`)
The `start_time` field accepts various time formats:
#### 24-Hour Format
```json
{
"start_time": "14:30" // 2:30 PM
}
```
#### 12-Hour Format with AM/PM
```json
{
"start_time": "2:30 PM" // 2:30 PM
}
```
#### Compact Format
```json
{
"start_time": "1430" // 2:30 PM
}
```
#### With Seconds
```json
{
"start_time": "14:30:45" // 2:30:45 PM
}
```
#### With Microseconds
```json
{
"start_time": "14:30:45.123456"
}
```
#### European Format
```json
{
"start_time": "14h30" // 2:30 PM
}
```
#### Short Formats
```json
{
"start_time": "14" // 2:00 PM
}
```
```json
{
"start_time": "2PM" // 2:00 PM
}
```
#### Decimal Time
```json
{
"start_time": "14.5" // 2:30 PM (14:30)
}
```
#### With Timezones
```json
{
"start_time": "14:30 UTC"
}
```
```json
{
"start_time": "2:30 PM EST"
}
```
```json
{
"start_time": "14:30 +05:30"
}
```
### Duration (`duration`)
The `duration` field supports multiple formats for maximum flexibility:
#### ISO 8601 Duration Format (Recommended)
```json
{
"duration": "PT2H30M" // 2 hours 30 minutes
}
```
```json
{
"duration": "PT3H" // 3 hours
}
```
```json
{
"duration": "PT90M" // 90 minutes
}
```
```json
{
"duration": "PT1H30M45S" // 1 hour 30 minutes 45 seconds
}
```
#### Human-Readable String Format
The system accepts natural language duration strings:
```json
{
"duration": "2 hours 30 minutes"
}
```
```json
{
"duration": "3 hours"
}
```
```json
{
"duration": "90 minutes"
}
```
```json
{
"duration": "1 hour 30 minutes 45 seconds"
}
```
```json
{
"duration": "2 days 5 hours"
}
```
```json
{
"duration": "1 day 2 hours 30 minutes"
}
```
#### Singular and Plural Forms
Both singular and plural forms are supported:
```json
{
"duration": "1 day" // Singular
}
```
```json
{
"duration": "2 days" // Plural
}
```
```json
{
"duration": "1 hour" // Singular
}
```
```json
{
"duration": "5 hours" // Plural
}
```
#### Numeric Formats
##### Seconds as Integer
```json
{
"duration": 3600 // 3600 seconds = 1 hour
}
```
```json
{
"duration": 1800 // 1800 seconds = 30 minutes
}
```
##### Seconds as Float
```json
{
"duration": 3600.5 // 3600.5 seconds = 1 hour 0.5 seconds
}
```
##### Tuple Format [days, hours, minutes, seconds]
```json
{
"duration": [0, 2, 30, 0] // 0 days, 2 hours, 30 minutes, 0 seconds
}
```
```json
{
"duration": [1, 0, 0, 0] // 1 day
}
```
```json
{
"duration": [0, 0, 45, 30] // 45 minutes 30 seconds
}
```
```json
{
"duration": [2, 5, 15, 45] // 2 days, 5 hours, 15 minutes, 45 seconds
}
```
#### Mixed Time Units
You can combine different time units in string format:
```json
{
"duration": "1 day 4 hours 30 minutes 15 seconds"
}
```
```json
{
"duration": "3 days 2 hours"
}
```
```json
{
"duration": "45 minutes 30 seconds"
}
```
#### Common Duration Examples
##### Short Durations
```json
{
"duration": "30 minutes" // Quick appliance cycle
}
```
```json
{
"duration": "PT30M" // ISO format equivalent
}
```
```json
{
"duration": 1800 // Numeric equivalent (seconds)
}
```
##### Medium Durations
```json
{
"duration": "2 hours 15 minutes"
}
```
```json
{
"duration": "PT2H15M" // ISO format equivalent
}
```
```json
{
"duration": [0, 2, 15, 0] // Tuple format equivalent
}
```
##### Long Durations
```json
{
"duration": "1 day 8 hours" // All-day appliance window
}
```
```json
{
"duration": "PT32H" // ISO format equivalent
}
```
```json
{
"duration": [1, 8, 0, 0] // Tuple format equivalent
}
```
#### Validation Rules for Duration
- **ISO 8601 format**: Must start with `PT` and use valid duration specifiers (H, M, S)
- **String format**: Must contain valid time units (day/days, hour/hours, minute/minutes, second/seconds)
- **Numeric format**: Must be a positive number representing seconds
- **Tuple format**: Must be exactly 4 elements: [days, hours, minutes, seconds]
- **All formats**: Duration must be positive (greater than 0)
#### Duration Format Recommendations
1. **Use ISO 8601 format** for API consistency: `"PT2H30M"`
2. **Use human-readable strings** for configuration files: `"2 hours 30 minutes"`
3. **Use numeric format** for programmatic calculations: `9000` (seconds)
4. **Use tuple format** for structured data: `[0, 2, 30, 0]`
#### Error Handling for Duration
Common duration errors and solutions:
- **Invalid ISO format**: Ensure proper `PT` prefix and valid specifiers
- **Unknown time units**: Use day/days, hour/hours, minute/minutes, second/seconds
- **Negative duration**: All durations must be positive
- **Invalid tuple length**: Tuple must have exactly 4 elements
- **String too long**: Duration strings have a maximum length limit for security
## Day of Week Restrictions
### Using Numbers (0=Monday, 6=Sunday)
```json
{
"day_of_week": 0 // Monday
}
```
```json
{
"day_of_week": 6 // Sunday
}
```
### Using English Day Names
```json
{
"day_of_week": "Monday"
}
```
```json
{
"day_of_week": "sunday" // Case insensitive
}
```
### Using Localized Day Names
```json
{
"day_of_week": "Montag", // German for Monday
"locale": "de"
}
```
```json
{
"day_of_week": "Lundi", // French for Monday
"locale": "fr"
}
```
## Date Restrictions
### Specific Date
```json
{
"date": "2024-12-25" // Christmas Day 2024
}
```
**Note**: When `date` is specified, `day_of_week` is ignored.
## Complete Examples
### Example 1: Basic Daily Window
Allow appliance to run between 9:00 AM and 11:00 AM every day:
```json
{
"windows": [
{
"start_time": "09:00",
"duration": "PT2H"
}
]
}
```
### Example 2: Weekday Only
Allow appliance to run between 8:00 AM and 6:00 PM on weekdays:
```json
{
"windows": [
{
"start_time": "08:00",
"duration": "PT10H",
"day_of_week": 0
},
{
"start_time": "08:00",
"duration": "PT10H",
"day_of_week": 1
},
{
"start_time": "08:00",
"duration": "PT10H",
"day_of_week": 2
},
{
"start_time": "08:00",
"duration": "PT10H",
"day_of_week": 3
},
{
"start_time": "08:00",
"duration": "PT10H",
"day_of_week": 4
}
]
}
```
### Example 3: Multiple Daily Windows
Allow appliance to run during morning and evening hours:
```json
{
"windows": [
{
"start_time": "06:00",
"duration": "PT3H"
},
{
"start_time": "18:00",
"duration": "PT4H"
}
]
}
```
### Example 4: Weekend Special Hours
Different hours for weekdays and weekends:
```json
{
"windows": [
{
"start_time": "08:00",
"duration": "PT8H",
"day_of_week": "Monday"
},
{
"start_time": "08:00",
"duration": "PT8H",
"day_of_week": "Tuesday"
},
{
"start_time": "08:00",
"duration": "PT8H",
"day_of_week": "Wednesday"
},
{
"start_time": "08:00",
"duration": "PT8H",
"day_of_week": "Thursday"
},
{
"start_time": "08:00",
"duration": "PT8H",
"day_of_week": "Friday"
},
{
"start_time": "10:00",
"duration": "PT6H",
"day_of_week": "Saturday"
},
{
"start_time": "10:00",
"duration": "PT6H",
"day_of_week": "Sunday"
}
]
}
```
### Example 5: Holiday Schedule
Special schedule for a specific date:
```json
{
"windows": [
{
"start_time": "10:00",
"duration": "PT4H",
"date": "2024-12-25"
}
]
}
```
### Example 6: Localized Configuration
Using German day names:
```json
{
"windows": [
{
"start_time": "14:00",
"duration": "PT2H",
"day_of_week": "Montag",
"locale": "de"
},
{
"start_time": "14:00",
"duration": "PT2H",
"day_of_week": "Mittwoch",
"locale": "de"
},
{
"start_time": "14:00",
"duration": "PT2H",
"day_of_week": "Freitag",
"locale": "de"
}
]
}
```
### Example 7: Complex Schedule with Timezones
Multiple windows with different timezones:
```json
{
"windows": [
{
"start_time": "09:00 UTC",
"duration": "PT4H",
"day_of_week": "Monday"
},
{
"start_time": "2:00 PM EST",
"duration": "PT3H",
"day_of_week": "Friday"
}
]
}
```
### Example 8: Night Shift Schedule
Crossing midnight (note: each window is within a single day):
```json
{
"windows": [
{
"start_time": "22:00",
"duration": "PT2H"
},
{
"start_time": "00:00",
"duration": "PT6H"
}
]
}
```
## Advanced Usage Patterns
### Off-Peak Hours
Configure appliance to run during off-peak electricity hours:
```json
{
"windows": [
{
"start_time": "23:00",
"duration": "PT1H"
},
{
"start_time": "00:00",
"duration": "PT7H"
}
]
}
```
### Workday Lunch Break
Allow appliance to run during lunch break on workdays:
```json
{
"windows": [
{
"start_time": "12:00",
"duration": "PT1H",
"day_of_week": 0
},
{
"start_time": "12:00",
"duration": "PT1H",
"day_of_week": 1
},
{
"start_time": "12:00",
"duration": "PT1H",
"day_of_week": 2
},
{
"start_time": "12:00",
"duration": "PT1H",
"day_of_week": 3
},
{
"start_time": "12:00",
"duration": "PT1H",
"day_of_week": 4
}
]
}
```
### Seasonal Schedule
Different schedules for different dates:
```json
{
"windows": [
{
"start_time": "08:00",
"duration": "PT10H",
"date": "2024-06-21"
},
{
"start_time": "09:00",
"duration": "PT8H",
"date": "2024-12-21"
}
]
}
```
## Common Patterns
### 1. Always Available
```json
{
"windows": [
{
"start_time": "00:00",
"duration": "PT24H"
}
]
}
```
### 2. Business Hours
```json
{
"windows": [
{
"start_time": "09:00",
"duration": "PT8H",
"day_of_week": 0
},
{
"start_time": "09:00",
"duration": "PT8H",
"day_of_week": 1
},
{
"start_time": "09:00",
"duration": "PT8H",
"day_of_week": 2
},
{
"start_time": "09:00",
"duration": "PT8H",
"day_of_week": 3
},
{
"start_time": "09:00",
"duration": "PT8H",
"day_of_week": 4
}
]
}
```
### 3. Never Available
```json
{
"windows": []
}
```
## Validation Rules
- `start_time` must be a valid time format
- `duration` must be a positive duration
- `day_of_week` must be 0-6 (integer) or valid day name (string)
- `date` must be a valid ISO date format (YYYY-MM-DD)
- If `date` is specified, `day_of_week` is ignored
- `locale` must be a valid locale code when using localized day names
## Tips and Best Practices
1. **Use 24-hour format** for clarity: `"14:30"` instead of `"2:30 PM"`
2. **Keep durations reasonable** for appliance operation cycles
3. **Test timezone handling** if using timezone-aware times
4. **Use specific dates** for holiday schedules
5. **Consider overlapping windows** for flexibility
6. **Use localization** for international deployments
7. **Document your patterns** for maintenance
## Error Handling
Common errors and solutions:
- **Invalid time format**: Use supported time formats listed above
- **Invalid duration**: Use ISO 8601 duration format (PT1H30M)
- **Invalid day name**: Check spelling and locale settings
- **Invalid date**: Use YYYY-MM-DD format
- **Unknown locale**: Use standard locale codes (en, de, fr, etc.)
## Integration Examples
### Python Usage
```python
from pydantic import ValidationError
try:
config = TimeWindowSequence.model_validate_json(json_string)
print(f"Configured {len(config.windows)} time windows")
except ValidationError as e:
print(f"Configuration error: {e}")
```
### API Configuration
```json
{
"device_id": "dishwasher_01",
"time_windows": {
"windows": [
{
"start_time": "22:00",
"duration": "PT2H"
},
{
"start_time": "06:00",
"duration": "PT2H"
}
]
}
}
```

View File

@@ -1,7 +1,6 @@
% SPDX-License-Identifier: Apache-2.0
(configuration-page)=
# Configuration Guideline
# Configuration
The configuration controls all aspects of EOS: optimization, prediction, measurement, and energy
management.
@@ -32,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,23 +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)=
### Resources
### Home Assistant Resources
- Duetting's [EOS Home Assistant Addon](https://github.com/Duetting/ha_eos_addon).
## 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.
- 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).

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,81 +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.
:::{admonition} Note
:class: note
The `/v1/logging/log` endpoint needs file logging to be enabled. Otherwise old or no logging
information is provided.
:::

View File

@@ -1,19 +1,25 @@
% SPDX-License-Identifier: Apache-2.0
(measurement-page)=
# Measurements
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
EOS stores measurements in a **key-value store**, where the term `measurement key` refers to the
unique identifier used to store and retrieve specific measurement data.
unique identifier used to store and retrieve specific measurement data. Note that the key-value
store is memory-based, meaning that all stored data will be lost upon restarting the EOS REST
server.
:::{admonition} Todo
:class: note
Ensure that measurement data persists across server restarts.
:::
Several endpoints of the EOS REST server allow for the management and retrieval of these
measurements.
@@ -24,10 +30,10 @@ The measurement data must be or is provided in one of the following formats:
A dictionary with the following structure:
```json
```python
{
"start_datetime": "2024-01-01 00:00:00",
"interval": "1 hour",
"interval": "1 Hour",
"<measurement key>": [value, value, ...],
"<measurement key>": [value, value, ...],
...
@@ -45,84 +51,43 @@ The column name of the data must be the same as the names of the `measurement ke
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).
Creates a dictionary like this:
```json
{
"data": {
"2024-01-01T00:00:00+01:00": 1,
"2024-01-02T00:00:00+01:00": 2,
"2024-01-03T00:00:00+01:00": 3,
...
},
"dtype": "float64",
"tz": "Europe/Berlin"
}
```
## Load Measurement
The EOS measurement store provides for storing energy meter readings of loads.
The EOS measurement store provides for storing meter readings of loads. There are currently five loads
foreseen. The associated `measurement key`s are:
The associated `measurement key`s can be configured by:
- `load0_mr`: Load0 meter reading [kWh]
- `load1_mr`: Load1 meter reading [kWh]
- `load2_mr`: Load2 meter reading [kWh]
- `load3_mr`: Load3 meter reading [kWh]
- `load4_mr`: Load4 meter reading [kWh]
```json
{
"measurement": {
"load_emr_keys": ["load0_emr", "my special load", ...]
}
}
```
For ease of use, you can assign descriptive names to the `measurement key`s to represent your
system's load sources. Use the following `configuration options` to set these names
(e.g., 'Dish Washer', 'Heat Pump'):
- `load0_name`: Name of the load0 source
- `load1_name`: Name of the load1 source
- `load2_name`: Name of the load2 source
- `load3_name`: Name of the load3 source
- `load4_name`: Name of the load4 source
Load measurements can be stored for any datetime. The values between different meter readings are
linearly approximated. Storing values between optimization intervals is generally not useful.
linearly approximated. Since optimization occurs on the hour, storing values between hours is
generally not useful.
The EOS measurement store automatically sums all given loads to create a total load value series
for specified intervals, usually one hour. This aggregated data can be used for load predictions.
:::{admonition} Warning
:class: warning
Only use **actual meter readings** in **kWh**, not energy consumption.
Example: `112345.77`, `112389.23`, `112412.55`, …
:::
## Grid Export/ Import Measurement
The EOS measurement store also allows for the storage of meter readings for grid import and export.
The associated `measurement key`s are:
The associated `measurement key`s can be configured by:
```json
{
"measurement": {
"grid_export_emr_keys": ["grid_export_emr", ...],
"grid_import_emr_keys": ["grid_import_emr", ...],
}
}
```
- `grid_export_mr`: Export to grid meter reading [kWh]
- `grid_import_mr`: Import from grid meter reading [kWh]
:::{admonition} Todo
:class: note
Currently not used. Integrate grid meter readings into the respective predictions.
:::
## Battery/ Electric Vehicle State of Charge (SoC) Measurement
The state of charge (SoC) measurement of batteries and electric vehicle batteries can be stored.
The associated `measurement key` is pre-defined by the device configuration. It can be
determined from the device configuration by the read-only `measurement_key_soc_factor` configuration
option.
## Battery/ Electric Vehicle Power Measurement
The charge/ discharge power measurements of batteries and electric vehicle batteries can be stored.
Charging power is denoted by a negative value, discharging power by a positive value.
The associated `measurement key`s are pre-defined by the device configuration. They can be
determined from the device configuration by read-only configuration options:
- `measurement_key_power_l1_w`
- `measurement_key_power_l2_w`
- `measurement_key_power_l3_w`
- `measurement_key_power_3_phase_sym_w`

View File

@@ -1,448 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
# Automatic Optimization
## Introduction
EOS offers two approaches to optimize your energy management system: `post /optimize optimization` and
`automatic optimization`.
The `post /optimize optimization` interface, based on a **POST** request to `/optimize`, is widely
used. It was originally developed by Andreas at the start of the project and is still demonstrated
in his instructional videos. This interface allows users or external systems to trigger an
optimization manually, supplying custom parameters and timing.
As an alternative, EOS supports `automatic optimization`, which runs automatically at configured
intervals. It retrieves all required input data — including electricity prices, battery storage
capacity, PV production forecasts, and temperature data — based on your system configuration.
### Genetic Algorithm
Both optimization modes use the same core optimization engine.
EOS uses a [genetic algorithm](https://en.wikipedia.org/wiki/Genetic_algorithm) to find an optimal
control strategy for home energy devices such as household loads, batteries, and electric vehicles.
In this context, each **individual** represents a possible solution — a specific control schedule
that defines how devices should operate over time. These individuals are evaluated using
[resource simulations](#resource-page), which model the systems energy behavior over a defined time
period divided into fixed intervals.
The quality of each solution (its *fitness*) is determined by how well it performs during
simulation, based on objectives such as minimizing electricity costs, maximizing self-consumption,
or meeting battery charge targets.
Through an iterative process of selection, crossover, and mutation, the algorithm gradually evolves
more effective solutions. The final result is an optimized control strategy that balances multiple
system goals within the constraints of the input data and configuration.
:::{note}
You dont need to understand the internal workings of the genetic algorithm to benefit from
automatic optimization. EOS handles everything behind the scenes based on your configuration.
However, advanced users can fine-tune the optimization behavior using additional settings like
population size, penalties, and random seed.
:::
## Energy Management Plan
Whenever the optimization is run, the energy management plan is updated. The energy management plan
provides a list of energy management instructions in chronological order. The instructions lean on
to the [S2 standard](https://docs.s2standard.org/) to have maximum flexibility and stay completely
independent from any manufacturer.
### Battery Instructions
The battery control instructions assume an idealized battery model. Under this model, the battery
can be operated in four discrete operation modes:
| **Operation Mode ID** | **Description** |
| --------------------- | ------------------------------------------------------------------------------------ |
| **IDLE** | Battery neither charges nor discharges; holds its state of charge. |
| **CHARGE** | Charge at a specified power rate up to the allowable maximum. |
| **DISCHARGE** | Discharge at a specified power rate up to the allowable maximum. |
| **ALLOW_DISCHARGE** | Allow the battery to freely discharge depending on its instantaneous power setpoint. |
The **operation mode factor** (0.01.0) specifies the normalized power rate relative to the
battery's nominal maximum charge or discharge power. A value of 1.0 corresponds to full-rate
charging or discharging, while 0.0 indicates no power transfer. Intermediate values scale the power
proportionally.
### Electric Vehicle Instructions
The electric vehicle control instructions assume an idealized EV battery model. Under this model,
the EV battery can be operated in two operation modes:
| **Operation Mode ID** | **Description** |
| --------------------- | ------------------------------------------------------------------------------------ |
| **IDLE** | Battery neither charges nor discharges; holds its state of charge. |
| **CHARGE** | Charge at a specified power rate up to the allowable maximum. |
The **operation mode factor** (0.01.0) specifies the normalized power rate relative to the
battery's nominal maximum charge power. A value of 1.0 corresponds to full-rate charging, while 0.0
indicates no power transfer. Intermediate values scale the power proportionally.
### Home Appliance Instructions
The home appliance instructions assume an idealized home appliance model. Under this model,
the home appliance can be operated in two operation modes:
| **Operation Mode ID** | **Description** |
| --------------------- | ------------------------------------------------------------------------------------ |
| **RUN** | The home appliance is started and runs until the end of it's power sequence. |
| **IDLE** | The home appliance does not run. |
The **operation mode factor** (0.01.0) is ignored.
## Configuration
### Energy management configuration
The energy management is run on configured intervals with some startup delay after server start.
Both values are given in seconds.
:::{admonition} Note
:class: note
If no interval is configured (`None`, `null`) there will be only one energy management run at
startup.
:::
The energy management can be run in two modes:
- **OPTIMIZATION**: A full optimization is done. This includes update of predictions.
- **PREDICTION**: Only the predictions are updated.
**Example:**
```json
{
"ems": {
"startup_delay": 5.0,
"interval": 300.0,
"mode": "OPTIMIZATION"
}
}
```
### Optimization Configuration
#### Optimization Time Configuration
- **horizon_hours**:
The optimization horizon parameter defines the default time window — in hours — within which
the energy optimization goal shall be achieved.
Specific devices, like the home appliance, have their own configuration for time windows. If
the time windows are not configured the simulation uses the default time window.
Each device simulation run must ensure that all tasks or appliance cycles (e.g., running a
dishwasher) are completed within the configured time windows.
- **interval**: Defines the time step in seconds between control actions
(e.g. `3600` for one hour, `900` for 15 minutes).
:::{warning}
**Current Limitation**
At present, the `interval` setting is **not used** by the genetic algorithm. Instead:
- The control interval is fixed to **1 hour**.
Support for configurable intervals (e.g. 15-minute steps) may be added in a future release.
:::
#### Genetic Algorithm Parameters
The behavior of the genetic algorithm can be customized using the following configuration options:
- **individuals** (`int`, default: `300`):
Sets the number of individuals (candidate solutions) in the (first) generation. A higher number
increases solution diversity and the chance of finding a good result, but also increases
computation time.
- **generations** (`int`, default: `400`):
Sets the number of generations to evaluate the optimal solution. In each generation, solutions are
evaluated and evolved. More generations can improve optimization quality but increase computation
time. Best results are usually found within a moderate number of generations.
- **seed** (`int` or `null`, default: `null`):
Sets the random seed for reproducible results.
- If `null`, a random seed is used (non-reproducible).
- If an integer is provided, it ensures that the same optimization input yields the same output.
A fixed seed to ensure reproducibility. Runs with the same seed and configuration will
produce the same results.
- **penalties** (`dict`):
Defines how penalties are applied to solutions that violate constraints (e.g., undercharged
batteries). Penalty function parameter values influence the fitness score, discouraging
undesirable solutions.
:::{note}
**Supported Penalty Functions**
Currently, the only supported penalty function parameter is:
- `ev_soc_miss`:
Applies a penalty when the **state of charge (SOC)** of the electric vehicle battery falls below
the required minimum. This encourages the optimizer to ensure sufficient EV charging.
:::
#### Value Formats
- **Time-related values**:
- `hours`: specified in **hours** (e.g. `24`)
- `interval`: specified in **seconds** (e.g. `3600`)
- **Genetic algorithm parameters**:
- `individuals`: must be an **integer**
- `seed`: must be an **integer** or `null` for random behavior
- **Penalty function parameter values**: may be `float`, `int`, or `string`, depending on the type
of penalty function.
#### Optimization configuration example
```json
{
"optimization": {
"hours": 24,
"interval": 3600,
"genetic" : {
"individuals": 300,
"generations": 400,
"seed": null,
"penalties": {
"ev_soc_miss": 10
}
}
}
}
```
### Device simulation configuration
The device simulations are used to evaluate the fitness of the individuals of the solution
population.
The GENETIC algorithm supports 4 devices:
- **inverter**: A photovoltaic power inverter that can export to the grid and charge a battery.
The inverter is mandatory.
- **electric_vehicle**: An electric vehicle, basically the battery of an electric vehicle. The
The electrical vehicle is optional.
- **battery**: A battery that can be charged by the inverter. The battery is mandatory.
- **home_appliance**: A home appliance, like a washing machine or a dish washer. The home
appliance is optional.
:::{admonition} Warning
:class: warning
The GENETIC algorithm can only use the first inverter, electrical vehicle, battery, home appliance
that is configured, even if more devices are configured.
:::
#### Inverter simulation configuration
**Example:**
```json
{
"devices": {
"max_inverters": 1,
"inverters": [
{
"device_id": "inv1",
"max_power_w": 10000,
"battery_id": "bat1"
}
]
}
}
```
#### Electric vehicle simulation configuration
**Example:**
```json
{
"devices": {
"max_electric_vehicles": 1,
"electric_vehicles": [
{
"device_id": "ev1",
"capacity_wh": 50000,
"max_charge_power_w": 10000,
"charge_rates": [0.0, 0.25, 0.5, 0.75, 1.0],
"min_soc_percentage": 10,
"max_soc_percentage": 80
}
]
},
"measurement": {
"electric_vehicle_soc_keys": ["ev1_soc"]
}
}
```
#### Battery simulation configuration
**Example:**
```json
{
"devices": {
"max_batteries": 1,
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 8000,
"min_charge_power_w": 50,
"charge_rates": null,
"min_soc_percentage": 5,
"max_soc_percentage": 95
}
]
}
}
```
#### Home appliance simulation configuration
**Example:**
```json
{
"devices": {
"max_home_appliances": 1,
"home_appliances": [
{
"device_id": "washing machine",
"consumption_wh": 600,
"duration_h": 3,
"time_windows": null,
}
]
}
}
```
The time windows the home appliance may run can be [configured](#configtimewindow-page) in several
ways. See the [time window configuration](#configtimewindow-page) for details.
## Predictions configuration
The device simulation may rely on predictions to simulate proper behaviour. E.g. the inverter needs
to know the PV forecast.
Configure the [predictions](#prediction-page) as described on the [prediction page](#prediction-page).
### Providing your own prediction data
If EOS does not have a suitable prediction provider you can provide your own data for a prediction.
Configure the respective import provider (ElecPriceImport, LoadImport, PVForecastImport,
WeatherImport) and use one of the following endpoints to provide your own data:
- **PUT** `/v1/prediction/import/ElecPriceImport`
- **PUT** `/v1/prediction/import/LoadImport`
- **PUT** `/v1/prediction/import/PVForecastImport`
- **PUT** `/v1/prediction/import/WeatherImport`
## Measurement configuration
Predictions and device simulations often rely on **measurement data** to produce accurate results.
For example:
- A **load forecast** requires past energy meter readings.
- A **battery simulation** needs the current **state of charge (SoC)** to start from the correct
condition.
Before using these features, make sure to configure the [measurement](#measurement-page) as
described on the [measurement page](#measurement-page).
### Providing your own measurement data
You can provide your own measurement data to the prediction and simulation engine through the
following REST endpoints (see the [measurement page](#measurement-page) for details on the data
format):
- **PUT** `/v1/measurement/data`
- **PUT** `/v1/measurement/dataframe`
- **PUT** `/v1/measurement/series`
- **PUT** `/v1/measurement/value`
### Example: Supplying Battery and EV SoC
For **batteries** and **electric vehicles**, it is strongly recommended to provide
**current SoC**. This ensures that simulations start with the correct state.
The simplest way is to use the `/v1/measurement/value` endpoint.
Assuming the battery is named `battery1` and the EV is named `ev11`:
1. **Use the measurement keys** that are pre-configured for your **devices**. For example:
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1", "capacity_wh": 8000, ...
"measurement_key_soc_factor": "battery1-soc-factor", ...
}
],
"electric_vehicles": [
{
"device_id": "ev11", "capacity_wh": 8000, ...
"measurement_key_soc_factor": "ev11-soc-factor", ...
}
]
}
}
```
2. **Record your SoC readings** to these keys.
- Enter the values as **factor of total capacity** of the respective **battery**.
In these examples:
- datetime specifies the timestamp of the measurement.
- key is the measurement key (e.g. battery1-soc-factor).
- value is the numeric measurement value (e.g. SoC as factor of total capacity).
#### Raw HTTP request
```http
PUT http://127.0.0.1:8503/v1/measurement/value?datetime=2025-09-26T16%3A39&key=battery1-soc-factor&value=0.57
PUT http://127.0.0.1:8503/v1/measurement/value?datetime=2025-09-26T16%3A39&key=ev11-soc-factor&value=0.22
```
#### Equivalent curl commands
```bash
curl -X PUT "http://127.0.0.1:8503/v1/measurement/value?datetime=2025-09-26T16%3A39&key=battery1-soc-factor&value=0.57"
curl -X PUT "http://127.0.0.1:8503/v1/measurement/value?datetime=2025-09-26T16%3A39&key=ev11-soc-factor&value=0.22"
```
### Example: Supplying Load Data
To provide your actual load measurements in Akkudoktor-EOS:
1. **Configure the measurement keys** for your load energy meters. For example:
```json
{
"measurements": {
"load_emr_keys": ["my_load_meter_reading", "my_other_load_meter_reading"]
}
}
```
2. **Record your meter readings** to these keys.
- Enter the values exactly as your energy meters report them, in **kWh**.
- Use the same approach as when supplying battery or EV SoC data.

View File

@@ -0,0 +1,8 @@
% SPDX-License-Identifier: Apache-2.0
# Optimization
:::{admonition} Todo
:class: note
Describe optimization.
:::

View File

@@ -1,253 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
# `POST /optimize` 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.
The `POST /optimize` optimization interface is the "classical" interface developed by Andreas at the
start of the projects and used and described in his videos. It allows and requires to define all the
optimization paramters on the endpoint request.
:::{admonition} Warning
:class: warning
The `POST /optimize` endpoint interface does not regard configurations set for the parameters
passed to the request. You have to set the parameters even if given in the configuration.
:::
## Input Payload
### Sample Request
```json
{
"ems": {
"preis_euro_pro_wh_akku": 0.0001,
"einspeiseverguetung_euro_pro_wh": [
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007
],
"gesamtlast": [
676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28,
1011.68, 995.00, 1053.07, 1063.91, 1320.56, 1132.03, 1163.67,
1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 988.56, 912.38,
704.61, 516.37, 868.05, 694.34, 608.79, 556.31, 488.89, 506.91,
804.89, 1141.98, 1056.97, 992.46, 1155.99, 827.01, 1257.98, 1232.67,
871.26, 860.88, 1158.03, 1222.72, 1221.04, 949.99, 987.01, 733.99,
592.97
],
"pv_prognose_wh": [
0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74,
1161.69, 6018.82, 5519.07, 3969.88, 3017.96, 1943.07, 1007.17,
319.67, 7.88, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5.04, 335.59, 705.32,
1121.12, 1604.79, 2157.38, 1433.25, 5718.49, 4553.96, 3027.55,
2574.46, 1720.4, 963.4, 383.3, 0, 0, 0
],
"strompreis_euro_pro_wh": [
0.0003384, 0.0003318, 0.0003284, 0.0003283, 0.0003289, 0.0003334,
0.0003290, 0.0003302, 0.0003042, 0.0002430, 0.0002280, 0.0002212,
0.0002093, 0.0001879, 0.0001838, 0.0002004, 0.0002198, 0.0002270,
0.0002997, 0.0003195, 0.0003081, 0.0002969, 0.0002921, 0.0002780,
0.0003384, 0.0003318, 0.0003284, 0.0003283, 0.0003289, 0.0003334,
0.0003290, 0.0003302, 0.0003042, 0.0002430, 0.0002280, 0.0002212,
0.0002093, 0.0001879, 0.0001838, 0.0002004, 0.0002198, 0.0002270,
0.0002997, 0.0003195, 0.0003081, 0.0002969, 0.0002921, 0.0002780
]
},
"pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400,
"max_charge_power_w": 5000,
"initial_soc_percentage": 80,
"min_soc_percentage": 15
},
"inverter": {
"device_id": "inverter1",
"max_power_wh": 10000,
"battery_id": "battery1"
},
"eauto": {
"device_id": "ev1",
"capacity_wh": 60000,
"charging_efficiency": 0.95,
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
"discharging_efficiency": 1.0,
"max_charge_power_w": 11040,
"initial_soc_percentage": 54,
"min_soc_percentage": 0
},
"temperature_forecast": [
18.3, 17.8, 16.9, 16.2, 15.6, 15.1, 14.6, 14.2, 14.3, 14.8, 15.7, 16.7, 17.4,
18.0, 18.6, 19.2, 19.1, 18.7, 18.5, 17.7, 16.2, 14.6, 13.6, 13.0, 12.6, 12.2,
11.7, 11.6, 11.3, 11.0, 10.7, 10.2, 11.4, 14.4, 16.4, 18.3, 19.5, 20.7, 21.9,
22.7, 23.1, 23.1, 22.8, 21.8, 20.2, 19.1, 18.0, 17.4
],
"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.0-1.0)
- `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.0-1.0)
#### 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
```

View File

@@ -1,28 +1,27 @@
% 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
- Feed In Tariff Prediction
- PV Power Prediction
- Weather Prediction
- **Household Load Prediction**
- **Electricity Price Prediction**
- **PV Power Prediction**
- **Weather Prediction**
## Storing Predictions
EOS stores predictions in a **key-value store**, where the term `prediction key` refers to the
unique key used to retrieve specific prediction data.
unique key used to retrieve specific prediction data. The key-value store is in memory. Stored
data is lost on re-start of the EOS REST server.
## Prediction Providers
Most predictions can be sourced from various providers. The specific provider to use is configured
in the EOS configuration and can be set by prediction type. For example:
```json
```python
{
"weather": {
"provider": "ClearOutside"
@@ -48,7 +47,7 @@ The prediction data must be provided in one of the following formats:
A dictionary with the following structure:
```json
```python
{
"start_datetime": "2024-01-01 00:00:00",
"interval": "1 Hour",
@@ -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,14 +116,11 @@ 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).
- `elecpriceimport.import_file_path`: Path to the file to import electricity price forecast data from.
- `elecpriceimport.import_json`: JSON string, dictionary of electricity price forecast value lists.
- `energycharts.bidding_zone`: Bidding zone Energy Charts shall provide price data for.
- `provider_settings.import_file_path`: Path to the file to import electricity price forecast data from.
- `provider_settings.import_json`: JSON string, dictionary of electricity price forecast value lists.
### ElecPriceAkkudoktor Provider
@@ -136,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
@@ -171,31 +147,11 @@ The electricity proce forecast data must be provided in one of the formats descr
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/ElecPriceImport` endpoint.
## Feed In Tariff Prediction
Prediction keys:
- `feed_in_tarif_wh`: Feed in tarif per Wh (€/Wh).
- `feed_in_tarif_kwh`: Feed in tarif per kWh (€/kWh)
Configuration options:
- `feedintarif`: Feed in tariff configuration.
- `provider`: Feed in tariff provider id of provider to be used.
- `FeedInTariffFixed`: Provides fixed feed in tariff values.
- `FeedInTariffImport`: Imports from a file or JSON string.
- `provider_settings.feed_in_tariff_kwh`: Fixed feed in tariff (€/kWh).
- `provider_settings.import_file_path`: Path to the file to import feed in tariff forecast data from.
- `provider_settings.import_json`: JSON string, dictionary of feed in tariff value lists.
## Load Prediction
Prediction keys:
- `loadforecast_power_w`: Predicted load mean value (W).
- `load_mean`: Predicted load mean value (W).
- `load_std`: Predicted load standard deviation (W).
- `load_mean_adjusted`: Predicted load mean value adjusted by load measurement (W).
@@ -206,55 +162,17 @@ 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.loadakkudoktor_year_energy_kwh`: Yearly energy consumption (kWh).
- `provider_settings.LoadVRM.load_vrm_token`: API token.
- `provider_settings.LoadVRM.load_vrm_idsite`: load_vrm_idsite.
- `provider_settings.LoadImport.loadimport_file_path`: Path to the file to import load forecast data from.
- `provider_settings.LoadImport.loadimport_json`: JSON string, dictionary of load forecast value lists.
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
- `provider_settings.loadimport_file_path`: Path to the file to import load forecast data from.
- `provider_settings.loadimport_json`: JSON string, dictionary of load forecast value lists.
### LoadAkkudoktor Provider
The `LoadAkkudoktor` provider retrieves generic load data from the local database and scales
it to match the annual energy consumption specified in the
`LoadAkkudoktor.loadakkudoktor_year_energy` configuration option.
### LoadAkkudoktorAdjusted Provider
The `LoadAkkudoktorAdjusted` provider retrieves generic load data from the local database and scales
it to match the annual energy consumption specified in the
`LoadAkkudoktor.loadakkudoktor_year_energy` configuration option. In addition, the provider refines
the forecast by incorporating available measured load data, ensuring a more realistic and
site-specific consumption profile.
For details on how to supply load measurements, see the [Measurements](measurement-page) section.
### 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.
```json
{
"load": {
"provider": "LoadVrm",
"provider_settings": {
"LoadVRM": {
"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).
The `LoadAkkudoktor` provider retrieves generic load data from a local database and tailors it to
align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration
option.
### LoadImport Provider
@@ -294,25 +212,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.
@@ -324,73 +233,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`
@@ -402,10 +277,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.
@@ -418,7 +292,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
@@ -433,8 +307,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.
@@ -455,56 +328,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
@@ -513,8 +364,8 @@ 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
@@ -547,7 +398,7 @@ Prediction keys:
- `weather_temp_air`: Temperature (°C)
- `weather_total_clouds`: Total Clouds (% Sky Obscured)
- `weather_visibility`: Visibility (m)
- `weather_wind_direction`: Wind Direction (°)
- `weather_wind_direction`: "Wind Direction (°)
- `weather_wind_speed`: Wind Speed (kmph)
Configuration options:
@@ -556,8 +407,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.
@@ -579,7 +430,7 @@ The provider provides forecast data for the following prediction keys:
- `weather_temp_air`: Temperature (°C)
- `weather_total_clouds`: Total Clouds (% Sky Obscured)
- `weather_visibility`: Visibility (m)
- `weather_wind_direction`: Wind Direction (°)
- `weather_wind_direction`: "Wind Direction (°)
- `weather_wind_speed`: Wind Speed (kmph)
### ClearOutside Provider
@@ -609,7 +460,7 @@ The provider provides forecast data for the following prediction keys:
- `weather_temp_air`: Temperature (°C)
- `weather_total_clouds`: Total Clouds (% Sky Obscured)
- `weather_visibility`: Visibility (m)
- `weather_wind_direction`: Wind Direction (°)
- `weather_wind_direction`: "Wind Direction (°)
- `weather_wind_speed`: Wind Speed (kmph)
### WeatherImport Provider
@@ -640,7 +491,7 @@ The prediction keys for the weather forecast data are:
- `weather_temp_air`: Temperature (°C)
- `weather_total_clouds`: Total Clouds (% Sky Obscured)
- `weather_visibility`: Visibility (m)
- `weather_wind_direction`: Wind Direction (°)
- `weather_wind_direction`: "Wind Direction (°)
- `weather_wind_speed`: Wind Speed (kmph)
The PV forecast data must be provided in one of the formats described in

View File

@@ -1,258 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
(resource-page)=
# Resources (Device Simulations)
## Concepts
The simulations for resources are leaning on general concepts of the [S2 standard].
### Control Types
The control of resources and such what a resource simulation will simulate follows three
basic control principles:
- Operation Mode Based Control (OMBC)
- Fill Rate Based Control (FRBC)
- Demand Driven Based Control (DDBC)
Although these control principles differ enough to separate them into three distinct control types,
there are some common aspects that make them similar:
- Operation Modes
- Transitions and
- Timers.
The objective for a control type is under which circumstances what things can be adjusted, and what
the constraints are for these adjustments. The three control types model a virtual, abstract resource
for simulation.
The abstract resource ignores all details of pyhsical device that are not relevant to energy
management. In addition, physical devices have an enormous variety in parameters, sensors, control
strategies, concerns, safeguards, and so on. It would be practically impossible to develop a
simulation that can
understand all the parameters of all the physical devices on the market. By making the resource more
abstract, its concepts can be translated to all sorts of physical devices, even though internally
they function very differently. As a consequence, it not always possible to make a 100% accurate
description of all the behaviors and constraints in these abstractions. But the abstractions used
in the control types are quite powerful, and should allow you to come pretty close.
The control types basically define how the simulated resource can be described. The user in the end
selects the proper desciption of a physical device using the configuration options provided for
resource simulations. The configuration sets how the simulated resource functions, what it can do and
what kind of constraints it has.
### Resource Simulation
Based on the description of this virtual resource, the resource simulation can make predictions of
what the physical device will do in certain situations, and when it is allowed to execute
instructions generated by the optimization as part of the energy management plan evaluation.
### Resource Status
Once the physical device has changed it's behavior, the resource simulation should be informed
to make the simulation change it's state accordingly.
The actual state of a pyhsical device may be reported to the resource simulation by the
**PUT** `/v1/resource/status` API endpoint.
## Battery
There is a wealth of possible battery operation modes:
<!-- pyml disable line-length -->
| Mode | Purpose / Behavior | Typical Trigger / Context |
| ------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| **IDLE** | Battery neither charges nor discharges (SOC stable). | No active control objective or power imbalance below thresholds. |
| **SELF_CONSUMPTION** | Charge from PV surplus and discharge to cover local load. | PV generation > load (charge) or load > PV (discharge). |
| **NON_EXPORT** | Charge from on-site or local surplus with the goal of minimizing or preventing energy export to the external grid. Discharging to the grid is not allowed. | Export limit reached and SOC < SOC_max. |
| **PEAK_SHAVING** | Discharge to keep grid import below a target threshold. | Predicted or measured site load exceeds peak limit. |
| **GRID_SUPPORT_EXPORT** | Discharge energy to grid for revenue (V2G, wholesale market, flexibility service). | Market or signal permits profitable export. |
| **GRID_SUPPORT_IMPORT** | Charge from grid to absorb surplus or provide up-regulation service. | Low-price or grid-support signal detected. |
| **FREQUENCY_REGULATION** | Rapid charge/discharge response to grid frequency deviations. | Active participation in frequency control. |
| **RAMP_RATE_CONTROL** | Smooth site-level power ramp rates by buffering fluctuations. | Sudden PV/load change exceeding ramp limit. |
| **RESERVE_BACKUP** | Maintain SOC reserve threshold to ensure backup capacity. | Resilience mode active, grid operational. |
| **OUTAGE_SUPPLY** | Islanded operation: power local loads using stored energy (and PV if available). | Grid failure detected. |
| **FORCED_CHARGE** | Manual or external control command to charge (e.g., pre-event, maintenance). No discharge. | Operator or optimizer command. |
| **FORCED_DISCHARGE** | Manual or external control command to discharge. No charge. | Operator or optimizer command. |
| **FAULT** | Battery unavailable due to fault, safety, or protection state. | Fault detected (thermal, voltage, comms, etc.). |
<!-- pyml enable line-length -->
The optimization algorithm, the device simulation and the configuration properties only support the
most important of these modes.
### Battery Simulation
The battery simulation assumes an idealized battery model. Under this model, the battery can be
operated in three discrete operation modes with fill rate based control (FRBC):
| **Operation Mode ID** | **Description** |
| ------------------------ | --------------------------------------------------------------------- |
| **SELF_CONSUMPTION** | Charge from local surplus and discharge to cover local load. |
| **NON_EXPORT** | Charge from local surplus and do not discharge. |
| **FORCED_CHARGE** | Charge. |
The **operation mode factor** (0.01.0) specifies the normalized power rate relative to the
battery's nominal maximum charge or discharge power. A value of 1.0 corresponds to full-rate
charging or discharging, while 0.0 indicates no power transfer. Intermediate values scale the power
proportionally.
The **fill level** (0.01.0) specifies the normalized fill level relative to the
battery's nominal maximum charge. A value of 1.0 corresponds to full while 0.0 indicates empty.
Intermediate values scale the fill level proportionally.
### Battery Configuration
### Battery Stati
To keep the battery simulation in synchonization with the actual stati of the battery the following
resource stati may be reported to EOS by the **PUT** `/v1/resource/status` API endpoint.
#### Battery FRBCActuatorStatus
The operation mode the battery is currently operated.
```json
{
"type": "FRBCActuatorStatus",
"active_operation_mode_id": "GRID_SUPPORT_IMPORT",
"operation_mode_factor": "0.375",
"previous_operation_mode_id": "SELF_CONSUMPTION",
"transistion_timestamp": "20250725T12:00:12"
}
```
#### Battery FRBCStorageStatus
The current battery state of charge (SoC).
```json
{
"type": "FRBCStorageStatus",
"present_fill_level": "0.88"
}
```
#### Battery PowerMeasurement
The current power that the battery is charged or discharged with \[W\].
```json
{
"type": "PowerMeasurement",
"measurement_timestamp": "20250725T12:00:12",
"values": [
{
"commodity_quantity": "ELECTRIC.POWER.L1",
"value": "887.5"
},
{
"commodity_quantity": "ELECTRIC.POWER.L2",
"value": "905.5"
},
{
"commodity_quantity": "ELECTRIC.POWER.L2",
"value": "1100.7"
},
]
}
```
For symmetric (or unknown) power distribution:
```json
{
"type": "PowerMeasurement",
"measurement_timestamp": "20250725T12:00:12",
"values": [
{
"commodity_quantity": "ELECTRIC.POWER.3_PHASE_SYM",
"value": "1000"
}
]
}
```
## Electric Vehicle
The electric vehicle is basically a battery with a reduced set of operation modes.
### Electric Vehicle Instructions
The electric vehicle control instructions assume an idealized EV battery model. Under this model,
the EV battery can be operated in two operation modes:
| **Operation Mode ID** | **Description** |
| --------------------- | ----------------------------------------------------------------------- |
| **IDLE** | Battery neither charges nor discharges; holds its state of charge. |
| **FORCED_CHARGE** | Charge at a specified power rate up to the allowable maximum. |
The **operation mode factor** (0.01.0) specifies the normalized power rate relative to the
battery's nominal maximum charge power. A value of 1.0 corresponds to full-rate charging, while 0.0
indicates no power transfer. Intermediate values scale the power proportionally.
## Home Appliance
The optimization algorithm supports one start of the home appliance within the optimization
horizon.
### Home Appliance Simulation
### Home Appliance Configuration
Home appliance to run within the optimization horizon.
```json
[
{
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3
}
]
```
Home appliance to run within a time window of 5 hours starting at 8:00 every day and another time
window of 3 hours starting at 15:00 every day. See
[Time Window Sequence Configuration](configtimewindow-page) for more information.
```json
[
{
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3,
"time_windows": {
"windows": [
{
"start_time": "08:00",
"duration": "5 hours"
},
{
"start_time": "15:00",
"duration": "3 hours"
}
]
}
}
]
```
:::{admonition} Note
:class: note
The optimization algorithm always restricts to one start within the optimization horizon per
energy management run.
:::
### Home Appliance Instructions
The home appliance instructions assume an idealized home appliance model. Under this model,
the home appliance can be operated in two operation modes:
| **Operation Mode ID** | **Description** |
|-----------------------|-------------------------------------------------------------------------|
| **RUN** | The home appliance is started and runs until the end of it's power |
| | sequence. |
| **IDLE** | The home appliance does not run. |
The **operation mode factor** (0.01.0) is ignored.

View File

@@ -1,5 +1,4 @@
% SPDX-License-Identifier: Apache-2.0
(server-api-page)=
# Server API

View File

@@ -7,20 +7,13 @@ https://www.sphinx-doc.org/en/master/usage/configuration.html
import sys
from pathlib import Path
# Add the src directory to sys.path so Sphinx can import akkudoktoreos
PROJECT_ROOT = Path(__file__).parent.parent
SRC_DIR = PROJECT_ROOT / "src"
sys.path.insert(0, str(SRC_DIR))
from akkudoktoreos.core.version import __version__
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = "Akkudoktor EOS"
copyright = "2025, Andreas Schmitz"
copyright = "2024, Andreas Schmitz"
author = "Andreas Schmitz"
release = __version__
release = "0.0.1"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@@ -29,7 +22,6 @@ extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.napoleon",
"sphinx.ext.todo",
"sphinx_rtd_theme",
"myst_parser",
"sphinx_tabs.tabs",
@@ -107,7 +99,7 @@ html_theme_options = {
"logo_only": False,
"titles_only": True,
}
html_css_files = ["eos.css"] # Make body size wider
html_css_files = ["eos.css"]
# -- Options for autodoc -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html

View File

@@ -1,4 +0,0 @@
```{include} ../../CHANGELOG.md
:relative-docs: ../
:relative-images:
```

View File

@@ -1,600 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
(develop-page)=
# Development Guide
## Development Prerequisites
Have or
[create](https://docs.github.com/en/get-started/start-your-journey/creating-an-account-on-github)
a [GitHub](https://github.com/) account.
Make shure all the source installation prequistes are installed. See the
[installation guideline](#install-page) for a detailed list of tools.
Under Linux the [make](https://www.gnu.org/software/make/manual/make.html) tool should be installed
as we have a lot of pre-fabricated commands for it.
Install your favorite editor or integrated development environment (IDE):
- Full-Featured IDEs
- [Eclipse + PyDev](https://www.pydev.org/)
- [KDevelop](https://www.kdevelop.org/)
- [PyCharm](https://www.jetbrains.com/pycharm/)
- ...
- Code Editors with Python Support
- [Visual Studio Code (VS Code)](https://code.visualstudio.com/)
- [Sublime Text](https://www.sublimetext.com/)
- [Atom / Pulsar](https://pulsar-edit.dev/)
- ...
- Python-Focused or Beginner-Friendly IDEs
- [Spyder](https://www.spyder-ide.org/)
- [Thonny](https://thonny.org/)
- [IDLE](https://www.python.org/downloads/)
- ...
## Step 1 Fork the Repository
[Fork the EOS repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo)
to your GitHub account.
Clone your fork locally and add the EOS upstream remote to track updates.
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
git clone https://github.com/<YOURUSERNAME>/EOS.git
cd EOS
git remote add eos https://github.com/Akkudoktor-EOS/EOS.git
.. tab:: Linux
.. code-block:: bash
git clone https://github.com/<YOURUSERNAME>/EOS.git
cd EOS
git remote add eos https://github.com/Akkudoktor-EOS/EOS.git
```
Replace `<YOURUSERNAME>` with your GitHub username.
## Step 2 Development Setup
This is recommended for developers who want to modify the source code and test changes locally.
### Step 2.1 Create a Virtual Environment
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
python -m venv .venv
.venv\Scripts\pip install --upgrade pip
.venv\Scripts\pip install -r requirements-dev.txt
.venv\Scripts\pip install build
.venv\Scripts\pip install -e .
.. tab:: Linux
.. code-block:: bash
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/pip install build
.venv/bin/pip install -e .
.. tab:: Linux Make
.. code-block:: bash
make install
```
### Step 2.2 Activate the Virtual Environment
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
.venv\Scripts\activate.bat
.. tab:: Linux
.. code-block:: bash
source .venv/bin/activate
```
### Step 2.3 - Install pre-commit
Our code style and commit message checks use [`pre-commit`](https://pre-commit.com).
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
pre-commit install
pre-commit install --hook-type commit-msg --hook-type pre-push
.. tab:: Linux
.. code-block:: bash
pre-commit install
pre-commit install --hook-type commit-msg --hook-type pre-push
```
## Step 3 - Run EOS
Make EOS accessible at [http://localhost:8503/docs](http://localhost:8503/docs) and EOSdash at
[http://localhost:8504](http://localhost:8504).
### Option 1 Using Python Virtual Environment
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
python -m akkudoktoreos.server.eos
.. tab:: Linux
.. code-block:: bash
python -m akkudoktoreos.server.eos
.. tab:: Linux Make
.. code-block:: bash
make run
```
To have full control of the servers during development you may start the servers independently -
e.g. in different terminal windows. Don't forget to activate the virtual environment in your
terminal window.
:::{admonition} Note
:class: note
If you killed or stopped the servers shortly before, the ports may still be occupied by the last
processes. It may take more than 60 seconds until the ports are released.
:::
You may add the `--reload true` parameter to have the servers automatically restarted on source code
changes. It is best to also add `--startup_eosdash false` to EOS to prevent the automatic restart
interfere with the EOS server trying to start EOSdash.
<!-- pyml disable line-length -->
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --log_level DEBUG --reload true
.. tab:: Linux
.. code-block:: bash
python -m akkudoktoreos.server.eosdash --host localhost --port 8504 --log_level DEBUG --reload true
.. tab:: Linux Make
.. code-block:: bash
make run-dash-dev
```
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
python -m akkudoktoreos.server.eos --host localhost --port 8503 --log_level DEBUG --startup_eosdash false --reload true
.. tab:: Linux
.. code-block:: bash
python -m akkudoktoreos.server.eos --host localhost --port 8503 --log_level DEBUG --startup_eosdash false --reload true
.. tab:: Linux Make
.. code-block:: bash
make run-dev
```
<!-- pyml enable line-length -->
### Option 2 Using Docker
#### Step 3.1 Build the Docker Image
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
docker build -t akkudoktoreos .
.. tab:: Linux
.. code-block:: bash
docker build -t akkudoktoreos .
```
#### Step 3.2 Run the Container
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
docker run -d `
--name akkudoktoreos `
-p 8503:8503 `
-p 8504:8504 `
-e OPENBLAS_NUM_THREADS=1 `
-e OMP_NUM_THREADS=1 `
-e MKL_NUM_THREADS=1 `
-e EOS_SERVER__HOST=0.0.0.0 `
-e EOS_SERVER__PORT=8503 `
-e EOS_SERVER__EOSDASH_HOST=0.0.0.0 `
-e EOS_SERVER__EOSDASH_PORT=8504 `
--ulimit nproc=65535:65535 `
--ulimit nofile=65535:65535 `
--security-opt seccomp=unconfined `
akkudoktor-eos:latest
.. tab:: Linux
.. code-block:: bash
docker run -d \
--name akkudoktoreos \
-p 8503:8503 \
-p 8504:8504 \
-e OPENBLAS_NUM_THREADS=1 \
-e OMP_NUM_THREADS=1 \
-e MKL_NUM_THREADS=1 \
-e EOS_SERVER__HOST=0.0.0.0 \
-e EOS_SERVER__PORT=8503 \
-e EOS_SERVER__EOSDASH_HOST=0.0.0.0 \
-e EOS_SERVER__EOSDASH_PORT=8504 \
--ulimit nproc=65535:65535 \
--ulimit nofile=65535:65535 \
--security-opt seccomp=unconfined \
akkudoktor-eos:latest
```
#### Step 3.3 Manage the Container
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
docker logs -f akkudoktoreos
docker stop akkudoktoreos
docker start akkudoktoreos
docker rm -f akkudoktoreos
.. tab:: Linux
.. code-block:: bash
docker logs -f akkudoktoreos
docker stop akkudoktoreos
docker start akkudoktoreos
docker rm -f akkudoktoreos
```
For detailed Docker instructions, refer to [Installation Guideline](install-page)
### Step 4 - Create the changes
#### Step 4.1 - Create a development branch
```bash
git checkout -b <MY_DEVELOPMENT_BRANCH>
```
Replace `<MY_DEVELOPMENT_BRANCH>` with the development branch name. The branch name shall be of the
format (feat|fix|chore|docs|refactor|test)/[a-z0-9._-]+, e.g:
- feat/my_cool_new_feature
- fix/this_annoying_bug
- ...
#### Step 4.2 Edit the sources
Use your fovourite editor or IDE to edit the sources.
#### Step 4.3 - Check the source code for correct format
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
pre-commit run --all-files
.. tab:: Linux
.. code-block:: bash
pre-commit run --all-files
.. tab:: Linux Make
.. code-block:: bash
make format
```
#### Step 4.4 - Test the changes
At a minimum, you should run the module tests:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
pytest -vs --cov src --cov-report term-missing
.. tab:: Linux
.. code-block:: bash
pytest -vs --cov src --cov-report term-missing
.. tab:: Linux Make
.. code-block:: bash
make test
```
:::{admonition} Note
:class: Note
Depending on your changes you may also have to change the version.py and documentation files. Do as
suggested by the tests. You may ignore the version.py and documentation changes up until you
finalize your change.
:::
You should also run the system tests. These include additional tests that interact with real
resources:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
pytest --system-test -vs --cov src --cov-report term-missing
.. tab:: Linux
.. code-block:: bash
pytest --system-test -vs --cov src --cov-report term-missing
.. tab:: Linux Make
.. code-block:: bash
make test-system
```
To do profiling use:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
python tests/single_test_optimization.py --profile
.. tab:: Linux
.. code-block:: bash
python tests/single_test_optimization.py --profile
.. tab:: Linux Make
.. code-block:: bash
make test-profile
```
#### Step 4.5 - Commit the changes
Add the changed and new files to the commit.
Create a commit.
### Step 5 - Pull request
Before creating a pull request assure the changes are based on the latest EOS upstream.
Update your local main branch:
```bash
git checkout main
git pull eos main
```
Switch back to your local development branch and rebase to main.
```bash
git checkout <MY_DEVELOPMENT_BRANCH>
git rebase -i main
```
During rebase you can also squash your changes into one (preferred) or a set of commits that have
proper commit messages and can easily be reviewed.
After rebase run the tests once again.
If everything is ok push the commit(s) to your fork on Github.
```bash
git push -f origin
```
If your push by intention does not comply to the rules you can skip the verification by:
```bash
git push -f --no-verify origin
```
<!-- pyml disable line-length -->
Once ready, [submit a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)
with your fork to the [Akkudoktor-EOS/EOS@master](https://github.com/Akkudoktor-EOS/EOS) repository.
<!-- pyml enable line-length -->
## Developer Tips
### Keep Your Fork Updated
Regularly pull changes from the eos repository to avoid merge conflicts:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
git checkout main
git pull eos main
git push origin
.. tab:: Linux
.. code-block:: bash
git checkout main
git pull eos main
git push origin
```
Rebase your development branch to the latest eos main branch.
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
git checkout <MY_DEVELOPMENT_BRANCH>
git rebase -i main
.. tab:: Linux
.. code-block:: bash
git checkout <MY_DEVELOPMENT_BRANCH>
git rebase -i main
```
### Create Feature Branches
Work in separate branches for each feature or bug fix:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
git checkout -b feat/my-feature
.. tab:: Linux
.. code-block:: bash
git checkout -b feat/my-feature
```
### Run Tests Frequently
Ensure your changes do not break existing functionality:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
pytest -vs --cov src --cov-report term-missing
.. tab:: Linux
.. code-block:: bash
pytest -vs --cov src --cov-report term-missing
.. tab:: Linux Make
.. code-block:: bash
make test
```
### Follow Coding Standards
Keep your code consistent with existing style and conventions.
### Use Issues for Discussion
Before making major changes, open an issue or discuss with maintainers.
### Document Changes
Update docstrings, comments, and any relevant documentation.

View File

@@ -1,86 +1,111 @@
% SPDX-License-Identifier: Apache-2.0
(getting-started-page)=
# Getting Started
## Installation and Running
## Installation
AkkudoktorEOS can be installed and run using several different methods:
The project requires Python 3.10 or newer. Currently there are no official packages or images published.
- **Release package** (for stable versions)
- **Docker image** (for easy deployment)
- **From source** (for developers)
Following sections describe how to locally start the EOS server on `http://localhost:8503`.
See the [installation guideline](#install-page) for detailed instructions on each method.
### Run from source
### Where to Find AkkudoktorEOS
Install the dependencies in a virtual environment:
- **Release Packages**: [GitHub Releases](https://github.com/Akkudoktor-EOS/EOS/releases)
- **Docker Images**: [Docker Hub](https://hub.docker.com/r/akkudoktor/eos)
- **Source Code**: [GitHub Repository](https://github.com/Akkudoktor-EOS/EOS)
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pip install -e .
.. tab:: Linux
.. code-block:: bash
python -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e .
```
Start the EOS fastapi server:
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
.venv\Scripts\python src/akkudoktoreos/server/eos.py
.. tab:: Linux
.. code-block:: bash
.venv/bin/python src/akkudoktoreos/server/eos.py
```
### Docker
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
docker compose up --build
.. tab:: Linux
.. code-block:: bash
docker compose up --build
```
## Configuration
AkkudoktorEOS uses the `EOS.config.json` file to manage all configuration settings.
This project uses the `EOS.config.json` file to manage configuration settings.
### Default Configuration
If essential configuration settings are missing, the application automatically uses a default
configuration to get you started quickly.
A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values.
### Custom Configuration Directory
### Custom Configuration
You can specify a custom location for your configuration by setting the `EOS_DIR` environment
variable:
Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`.
```bash
export EOS_DIR=/path/to/your/config
```
- 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`.
**How it works:**
### Configuration Updates
- **If `EOS.config.json` exists** in the `EOS_DIR` directory → the application uses this
configuration
- **If `EOS.config.json` doesn't exist** → the application copies `default.config.json` to `EOS_DIR`
as `EOS.config.json`
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.
### Creating Your Configuration
## Classes and Functionalities
There are three ways to configure AkkudoktorEOS:
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:
1. **EOSdash (Recommended)** - The easiest method is to use the web-based dashboard at
[http://localhost:8504](http://localhost:8504)
- `Battery`: Simulates a battery storage system, including capacity, state of charge, and now charge and discharge losses.
2. **Manual editing** - Create or edit the `EOS.config.json` file directly in your preferred text
editor
- `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and historical generation data.
3. **Server API** - Programmatically change configuration through the [server API](#server-api-page)
- `Load`: Models the load requirements of a household or business, enabling the prediction of future energy demand.
For a complete reference of all available configuration options, see the [configuration guideline](#configuration-page).
- `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various operating conditions.
## Quick Start Example
- `Strompreis`: Provides information on electricity prices, enabling optimization of energy consumption and generation based on tariff information.
```bash
# Pull the latest docker image
docker pull akkudoktor/eos:latest
- `EMS`: The Energy Management System (EMS) coordinates the interaction between the various components, performs optimization, and simulates the operation of the entire energy system.
# Run the application
docker run -d \
--name akkudoktoreos \
-p 8503:8503 \
-p 8504:8504 \
-e OPENBLAS_NUM_THREADS=1 \
-e OMP_NUM_THREADS=1 \
-e MKL_NUM_THREADS=1 \
-e EOS_SERVER__HOST=0.0.0.0 \
-e EOS_SERVER__PORT=8503 \
-e EOS_SERVER__EOSDASH_HOST=0.0.0.0 \
-e EOS_SERVER__EOSDASH_PORT=8504 \
--ulimit nproc=65535:65535 \
--ulimit nofile=65535:65535 \
--security-opt seccomp=unconfined \
akkudoktor/eos:latest
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.
# Access the dashboard
open http://localhost:8504
```
### 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.

View File

@@ -1,291 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
(install-page)=
# Installation Guide
This guide provides different methods to install AkkudoktorEOS:
- Installation from Source (GitHub) (M1)
- Installation from Release Package (GitHub) (M2)
- Installation with Docker (DockerHub) (M3)
- Installation with Docker (docker-compose) (M4)
Choose the method that best suits your needs.
:::{admonition} Tip
:class: Note
If you need to update instead, see the [Update Guideline](update-page). For reverting to a previous
release see the [Revert Guideline](revert-page).
:::
## Installation Prerequisites
Before installing, ensure you have the following:
### For Source / Release Installation
- Python 3.10 or higher
- pip
- Git (only for source)
- Tar/Zip (for release package)
### For Docker Installation
- Docker Engine 20.10 or higher
- Docker Compose (optional, recommended)
See [Install Docker Engine](https://docs.docker.com/engine/install/) on how to install docker on
your Linux distro.
## Installation from Source (GitHub) (M1)
Recommended for developers or users wanting the latest updates.
### 1) Clone the Repository (M1)
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
git clone https://github.com/Akkudoktor-EOS/EOS.git
cd EOS
.. tab:: Linux
.. code-block:: bash
git clone https://github.com/Akkudoktor-EOS/EOS.git
cd EOS
```
### 2) Create a Virtual Environment and install dependencies (M1)
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pip install -e .
.. tab:: Linux
.. code-block:: bash
python -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e .
```
### 3) Run EOS (M1)
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
.venv\Scripts\python -m akkudoktoreos.server.eos
.. tab:: Linux
.. code-block:: bash
.venv/bin/python -m akkudoktoreos.server.eos
```
EOS is now available at:
- API: [http://localhost:8503/docs](http://localhost:8503/docs)
- EOSdash: [http://localhost:8504](http://localhost:8504)
If you want to make EOS and EOSdash accessible from outside of your machine or container at this
stage of the installation provide appropriate IP addresses on startup.
<!-- pyml disable line-length -->
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
.venv\Scripts\python -m akkudoktoreos.server.eos --host 0.0.0.0 --eosdash-host 0.0.0.0
.. tab:: Linux
.. code-block:: bash
.venv/bin/python -m akkudoktoreos.server.eos --host 0.0.0.0 --eosdash-host 0.0.0.0
```
<!-- pyml enable line-length -->
### 4) Configure EOS (M1)
Use EOSdash at [http://localhost:8504](http://localhost:8504) to configure EOS.
## Installation from Release Package (GitHub) (M2)
This method is recommended for users who want a stable, tested version.
### 1) Download the Latest Release (M2)
Visit the [Releases page](https://github.com/Akkudoktor-EOS/EOS/tags) and download the latest
release package (e.g., `akkudoktoreos-v0.2.0.tar.gz` or `akkudoktoreos-v0.2.0.zip`).
### 2) Extract the Package (M2)
```bash
tar -xzf akkudoktoreos-v0.2.0.tar.gz # For .tar.gz
# or
unzip akkudoktoreos-v0.2.0.zip # For .zip
cd akkudoktoreos-v0.2.0
```
### 3) Create a virtual environment and run and configure EOS (M2)
Follow Step 2), 3) and 4) of method M1. Start at
`2) Create a Virtual Environment and install dependencies`
### 4) Update the source code (M2)
To extract a new release to a new directory just proceed with method M2 step 1) for the new release.
You may remove the old release directory afterwards.
## Installation with Docker (DockerHub) (M3)
This method is recommended for easy deployment and containerized environments.
### 1) Pull the Docker Image (M3)
```bash
docker pull akkudoktor/eos:latest
```
For a specific version:
```bash
docker pull akkudoktor/eos:v<version>
```
### 2) Run the Container (M3)
**Basic run:**
```bash
docker run -d \
--name akkudoktoreos \
-p 8503:8503 \
-p 8504:8504 \
-e OPENBLAS_NUM_THREADS=1 \
-e OMP_NUM_THREADS=1 \
-e MKL_NUM_THREADS=1 \
-e EOS_SERVER__HOST=0.0.0.0 \
-e EOS_SERVER__PORT=8503 \
-e EOS_SERVER__EOSDASH_HOST=0.0.0.0 \
-e EOS_SERVER__EOSDASH_PORT=8504 \
--ulimit nproc=65535:65535 \
--ulimit nofile=65535:65535 \
--security-opt seccomp=unconfined \
akkudoktor/eos:latest
```
### 3) Verify the Container is Running (M3)
```bash
docker ps
docker logs akkudoktoreos
```
EOS should now be accessible at [http://localhost:8503/docs](http://localhost:8503/docs) and EOSdash
should be available at [http://localhost:8504](http://localhost:8504).
### 4) Configure EOS (M3)
Use EOSdash at [http://localhost:8504](http://localhost:8504) to configure EOS.
## Installation with Docker (docker-compose) (M4)
### 1) Get the akkudoktoreos source code (M4)
You may use either method M1 or method M2 to get the source code.
### 2) Build and run the container (M4)
```{eval-rst}
.. tabs::
.. tab:: Windows
.. code-block:: powershell
docker compose up --build
.. tab:: Linux
.. code-block:: bash
docker compose up --build
```
### 3) Verify the Container is Running (M4)
```bash
docker ps
docker logs akkudoktoreos
```
EOS should now be accessible at [http://localhost:8503/docs](http://localhost:8503/docs) and EOSdash
should be available at [http://localhost:8504](http://localhost:8504).
### 4) Configure EOS
Use EOSdash at [http://localhost:8504](http://localhost:8504) to configure EOS.
## Helpful Docker Commands
**View logs:**
```bash
docker logs -f akkudoktoreos
```
**Stop the container:**
```bash
docker stop akkudoktoreos
```
**Start the container:**
```bash
docker start akkudoktoreos
```
**Remove the container:**
```bash
docker rm -f akkudoktoreos
```
**Update to latest version:**
```bash
docker pull Akkudoktor-EOS/EOS:latest
docker stop akkudoktoreos
docker rm akkudoktoreos
# Then run the container again with the run command
```

View File

@@ -1,147 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
(release-page)=
# Release Process
This document describes how to prepare and publish a new release **via a Pull Request from a fork**,
and how to set a **development version** after the release.
## ✅ Overview of the Process
| Step | Actor | Action |
|------|-------------|--------|
| 1 | Contributor | Prepare a release branch **in your fork** using Commitizen |
| 2 | Contributor | Open a **Pull Request to upstream** (`Akkudoktor-EOS/EOS`) |
| 3 | Maintainer | Review and **merge the release PR** |
| 4 | CI | Create the **GitHub Release and tag** |
| 5 | CI | Set the **development version marker** via a follow-up PR |
## 🔄 Detailed Workflow
### 1⃣ Contributor: Prepare the Release in Your Fork
#### Clone and sync your fork
```bash
git clone https://github.com/<your-username>/EOS
cd EOS
git remote add eos https://github.com/Akkudoktor-EOS/EOS
git fetch eos
git checkout main
git pull eos main
````
#### Create the release branch
```bash
git checkout -b release/vX.Y.Z
```
#### Bump the version information
Set `__version__` in src/akkudoktoreos/core/version.py
```python
__version__ = 0.3.0
```
Prepare version by updating versioned files, e.g.:
- haaddon/config.yaml
and the generated documentation:
```bash
make prepare-version
```
Check the changes by:
```bash
make test-version
```
#### Create a new CHANGELOG.md entry
Edit CHANGELOG.md
#### Create the new release commit
Add all the changed version files and all other changes to the commit.
```bash
git add src/akkudoktoreos/core/version.py CHANGELOG.md ...
git commit -s -m "chore: Prepare Release v0.3.0"
```
#### Push the branch to your fork
```bash
git push --set-upstream origin release/v0.3.0
```
### 2⃣ Contributor: Open the Release Preparation Pull Request
| From | To |
| ------------------------------------ | ------------------------- |
| `<your-username>/EOS:release/vX.Y.Z` | `Akkudoktor-EOS/EOS:main` |
**PR Title:**
```text
chore: prepare release vX.Y.Z
```
**PR Description Template:**
```markdown
## Prepare Release vX.Y.Z
This pull request prepares release **vX.Y.Z**.
### Changes
- Version bump
- Changelog update
### Changelog Summary
<!-- Copy key highlights from CHANGELOG.md here -->
See `CHANGELOG.md` for full details.
```
### 3⃣ Maintainer: Review and Merge the Release PR
**Review Checklist:**
- ✅ Only version files and `CHANGELOG.md` are modified
- ✅ Version numbers are consistent
- ✅ Changelog is complete and properly formatted
- ✅ No unrelated changes are included
**Merge Strategy:**
- Prefer **Merge Commit** (or **Squash Merge**, per project preference)
- Use commit message: `chore: Prepare Release vX.Y.Z`
### 4⃣ CI: Publish the GitHub Release
The new release will automatically be published by the GitHub CI action.
See `.github/workflwows/bump-version.yml`for details.
### 5⃣ CI: Prepare the Development Version Marker
The development version marker will automatically be set by the GitHub CI action.
See `.github/workflwows/bump-version.yml`for details.
## ✅ Quick Reference
| Step | Actor | Action |
| ---- | ----- | ------ |
| **1. Prepare release branch** | Contributor | Bump version & changelog |
| **2. Open release PR** | Contributor | Submit release for review |
| **3. Review & merge release PR** | Maintainer | Finalize changes into `main` |
| **4. Publish GitHub Release** | CI | Create tag & notify users |
| **5. Prepare development version branch** | CI | Set development marker |

View File

@@ -1,155 +0,0 @@
% SPDX-License-Identifier: Apache-2.0
(revert-page)=
# Revert Guide
This guide explains how to **revert AkkudoktorEOS to a previous version**.
The exact methods and steps differ depending on how EOS was installed:
- M1/M2: Reverting when Installed from Source or Release Package
- M3/M4: Reverting when Installed via Docker
:::{admonition} Important
:class: warning
Before reverting, ensure you have a backup of your `EOS.config.json`.
EOS also maintains internal configuration backups that can be restored after a downgrade.
:::
:::{admonition} Tip
:class: Note
If you need to update instead, see the [Update Guideline](update-page).
:::
## Revert to a Previous Version of EOS
You can revert to a previous version using the same installation method you originally selected.
See: [Installation Guideline](install-page)
## Reverting when Installed from Source or Release Package (M1/M2)
### 1) Locate the target version (M2)
Go to the GitHub Releases page:
> <https://github.com/Akkudoktor-EOS/EOS/tags>
### 2) Download or check out that version (M1/M2)
#### Git (source) (M1)
```bash
git fetch
git checkout v<version>
````
Example:
```bash
git checkout v0.1.0
```
Then reinstall dependencies:
```bash
.venv/bin/pip install -r requirements.txt --upgrade
```
#### Release package (M2)
Download and extract the desired ZIP or TAR release.
Refer to **Method 2** in the [Installation Guideline](install-page).
### 3) Restart EOS (M1/M2)
```bash
.venv/bin/python -m akkudoktoreos.server.eos
```
### 4) Restore configuration (optional) (M1/M2)
If your configuration changed since the downgrade, you may restore a previous backup:
- via **EOSdash**
Admin → configuration → Revert to backup
or
Admin → configuration → Import from file
- via **REST**
```bash
curl -X PUT "http://<host>:8503/v1/config/revert?backup_id=<backup>"
```
## Reverting when Installed via Docker (M3/M4)
### 1) Pull the desired image version (M3/M4)
```bash
docker pull akkudoktor/eos:v<version>
```
Example:
```bash
docker pull akkudoktor/eos:v0.1.0
```
### 2) Stop and remove the current container (M3/M4)
```bash
docker stop akkudoktoreos
docker rm akkudoktoreos
```
### 3) Start a container with the selected version (M3/M4)
Start EOS as usual, using your existing `docker run` or `docker compose` setup
(see Method 3 or Method 4 in the [Installation Guideline](install-page)).
### 4) Restore configuration (optional) (M3/M4)
In many cases configuration will migrate automatically.
If needed, you may restore a configuration backup:
- via **EOSdash**
Admin → configuration → Revert to backup
or
Admin → configuration → Import from file
- via **REST**
```bash
curl -X PUT "http://<host>:8503/v1/config/revert?backup_id=<backup>"
```
## About Configuration Backups
EOS keeps configuration backup files next to your active `EOS.config.json`.
You can list and restore backups:
- via **EOSdash UI**
- via **REST API**
### List available backups
```bash
GET /v1/config/backups
```
### Restore backup
```bash
PUT /v1/config/revert?backup_id=<id>
```
:::{admonition} Important
:class: warning
If no backup file is available, create or copy a previously saved `EOS.config.json` before reverting.
:::

View File

@@ -8,61 +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
develop/install.md
develop/update.md
develop/revert.md
```
```{toctree}
:maxdepth: 2
:caption: Reference
akkudoktoreos/architecture.md
akkudoktoreos/configuration.md
akkudoktoreos/configtimewindow.md
akkudoktoreos/optimpost.md
akkudoktoreos/optimauto.md
akkudoktoreos/resource.md
akkudoktoreos/optimization.md
akkudoktoreos/prediction.md
akkudoktoreos/measurement.md
akkudoktoreos/integration.md
akkudoktoreos/logging.md
akkudoktoreos/serverapi.md
akkudoktoreos/api.rst
```
```{toctree}
:maxdepth: 2
:caption: Development
develop/develop.md
develop/release.md
develop/CHANGELOG.md
```
## 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)

10475
openapi.json

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
[project]
name = "akkudoktor-eos"
dynamic = ["version"] # Get version information dynamically
version = "0.0.1"
authors = [
{ name="Andreas Schmitz", email="author@example.com" },
]
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",
@@ -25,8 +25,6 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]}
optional-dependencies = {dev = { file = ["requirements-dev.txt"] }}
# version.txt must be generated
version = { file = "version.txt" }
[tool.setuptools.packages.find]
where = ["src/"]
@@ -45,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
@@ -109,12 +101,3 @@ ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "xprocess.*"
ignore_missing_imports = true
[tool.commitizen]
# Only used as linter
name = "cz_conventional_commits"
version_scheme = "semver"
# Enforce commit message and branch style:
branch_validation = true
branch_pattern = "^(feat|fix|chore|docs|refactor|test)/[a-z0-9._-]+$"

View File

@@ -1,33 +1,13 @@
-r requirements.txt
# Pre-commit framework - basic package requirements handled by pre-commit itself
# - pre-commit-hooks
# - isort
# - ruff
# - mypy (mirrors-mypy) - sync with requirements-dev.txt (if on pypi)
# - pymarkdown
# - commitizen - sync with requirements-dev.txt (if on pypi)
#
# !!! Sync .pre-commit-config.yaml and requirements-dev.txt !!!
pre-commit==4.5.0
mypy==1.18.2
types-requests==2.32.4.20250913 # for mypy
pandas-stubs==2.3.2.250926 # for mypy
tokenize-rt==6.2.0 # for mypy
types-docutils==0.22.3.20251115 # for mypy
types-PyYaml==6.0.12.20250915 # for mypy
commitizen==4.10.0
deprecated==1.3.1 # for commitizen
# Sphinx
sphinx==8.2.3
gitpython==3.1.44
myst-parser==4.0.0
sphinx==8.1.3
sphinx_rtd_theme==3.0.2
sphinx-tabs==3.4.7
GitPython==3.1.45
myst-parser==4.0.1
# Pytest
pytest==9.0.1
pytest-cov==7.0.0
coverage==7.12.0
pytest==8.3.4
pytest-cov==6.0.0
pytest-xprocess==1.0.2
pre-commit
mypy==1.13.0
types-requests==2.32.0.20241016
pandas-stubs==2.2.3.241126

View File

@@ -1,31 +1,24 @@
babel==2.17.0
beautifulsoup4==4.14.2
cachebox==5.1.0
numpy==2.3.5
numpydantic==1.7.0
matplotlib==3.10.7
contourpy==1.3.3
fastapi[standard-no-fastapi-cloud-cli]==0.122.0
fastapi_cli==0.0.16
rich-toolkit==0.16.0
python-fasthtml==0.12.35
MonsterUI==1.0.32
cachebox==4.4.2
numpy==2.2.2
numpydantic==1.6.7
matplotlib==3.10.0
fastapi[standard]==0.115.7
python-fasthtml==0.12.0
MonsterUI==0.0.29
markdown-it-py==3.0.0
mdit-py-plugins==0.5.0
bokeh==3.8.1
uvicorn==0.38.0
scikit-learn==1.7.2
tzfpy==1.1.0
deap==1.4.3
requests==2.32.5
pandas==2.3.3
pendulum==3.1.0
platformdirs==4.5.0
psutil==7.1.3
pvlib==0.13.1
pydantic==2.12.4
pydantic_extra_types==2.10.6
statsmodels==0.14.5
pydantic-settings==2.11.0
mdit-py-plugins==0.4.2
bokeh==3.6.3
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
psutil==6.1.1
pvlib==0.11.2
pydantic==2.10.6
statsmodels==0.14.4
pydantic-settings==2.7.0
linkify-it-py==2.0.3
loguru==0.7.3

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""
Update VERSION_BASE in version.py after a release tag.
Behavior:
- Read VERSION_BASE from version.py
- Strip ANY existing "+dev" suffix
- Append exactly one "+dev"
- Write back the updated file
This ensures:
0.2.0 --> 0.2.0+dev
0.2.0+dev --> 0.2.0+dev
0.2.0+dev+dev -> 0.2.0+dev
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = ROOT / "src" / "akkudoktoreos" / "core" / "version.py"
def bump_dev_version_file(file: Path) -> str:
text = file.read_text(encoding="utf-8")
# Extract current version
m = re.search(r'^VERSION_BASE\s*=\s*["\']([^"\']+)["\']',
text, flags=re.MULTILINE)
if not m:
raise ValueError("VERSION_BASE not found")
base_version = m.group(1)
# Remove trailing +dev if present → ensure idempotency
cleaned = re.sub(r'(\+dev)+$', '', base_version)
# Append +dev
new_version = f"{cleaned}+dev"
# Replace inside file content
new_text = re.sub(
r'^VERSION_BASE\s*=\s*["\']([^"\']+)["\']',
f'VERSION_BASE = "{new_version}"',
text,
flags=re.MULTILINE
)
file.write_text(new_text, encoding="utf-8")
return new_version
def main():
# Use CLI argument or fallback default path
version_file = Path(sys.argv[1]) if len(sys.argv) > 1 else VERSION_FILE
try:
new_version = bump_dev_version_file(version_file)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# MUST print to stdout
print(new_version)
if __name__ == "__main__":
main()

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env python3
import subprocess
import sys
MESSAGE_PREFIX = "Converted to annotated tag:"
def run(cmd, capture_output=False):
"""Run a shell command and return output if needed."""
result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=capture_output)
return result.stdout.strip() if capture_output else None
def get_all_tags():
"""Return a list of all tags."""
return run("git tag", capture_output=True).splitlines()
def is_lightweight(tag):
"""Return True if a tag is lightweight (points to commit, not tag object)."""
return run(f"git cat-file -t {tag}", capture_output=True) == "commit"
def get_commit_of_tag(tag):
"""Return the commit SHA a tag points to."""
return run(f"git rev-list -n 1 {tag}", capture_output=True)
def convert_tag(tag):
"""Delete and recreate a tag as annotated."""
commit = get_commit_of_tag(tag)
print(f"Converting {tag} -> annotated ({commit})")
run(f"git tag -d {tag}")
run(f'git tag -a {tag} -m "{MESSAGE_PREFIX} {tag}" {commit}')
def main():
dry_run = "--dry-run" in sys.argv
push = "--push" in sys.argv
tags = get_all_tags()
lightweight_tags = [t for t in tags if is_lightweight(t)]
if not lightweight_tags:
print("✅ No lightweight tags found.")
return
print("🔍 Lightweight tags found:\n " + "\n ".join(lightweight_tags))
if dry_run:
print("\n📝 Dry run: No changes will be made.")
return
confirm = input("\n⚠️ Convert ALL of these tags to annotated? (y/N): ").lower()
if confirm != "y":
print("❌ Aborted.")
return
for tag in lightweight_tags:
convert_tag(tag)
print("\n✅ Conversion complete.")
if push:
print("📤 Pushing updated tags to origin (force)...")
run("git push origin --tags --force")
print("✅ Tags pushed.")
else:
print("\n🚀 To push changes, run:\n git push origin --tags --force")
if __name__ == "__main__":
print("=== Lightweight Tag Converter ===")
print("Usage: python convert_lightweight_tags.py [--dry-run] [--push]\n")
main()

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env python3
"""Branch name checker using regex (compatible with Commitizen v4.9.1).
Cross-platform + .venv aware.
"""
import os
import re
import subprocess
import sys
from pathlib import Path
def find_cz() -> str:
venv = os.getenv("VIRTUAL_ENV")
paths = [Path(venv)] if venv else []
paths.append(Path.cwd() / ".venv")
for base in paths:
cz = base / ("Scripts" if os.name == "nt" else "bin") / ("cz.exe" if os.name == "nt" else "cz")
if cz.exists():
return str(cz)
return "cz"
def main():
# Get current branch name
try:
branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], text=True).strip()
except subprocess.CalledProcessError:
print("❌ Could not determine current branch name.")
return 1
# Regex pattern
pattern = r"^(feat|fix|chore|docs|refactor|test)/[a-z0-9._-]+$"
print(f"🔍 Checking branch name '{branch}'...")
if not re.match(pattern, branch):
print(f"❌ Branch name '{branch}' does not match pattern '{pattern}'")
return 1
print("✅ Branch name is valid.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""Commitizen commit message checker that is .venv aware.
Works for commits with -m or commit message file.
"""
import os
import subprocess
import sys
from pathlib import Path
def find_cz() -> str:
"""Find Commitizen executable, preferring virtualenv."""
venv = os.getenv("VIRTUAL_ENV")
paths = []
if venv:
paths.append(Path(venv))
paths.append(Path.cwd() / ".venv")
for base in paths:
cz = base / ("Scripts" if os.name == "nt" else "bin") / ("cz.exe" if os.name == "nt" else "cz")
if cz.exists():
return str(cz)
return "cz"
def main():
cz = find_cz()
# 1⃣ Try commit-msg file (interactive commit)
commit_msg_file = sys.argv[1] if len(sys.argv) > 1 else None
# 2⃣ If not file, fallback to -m message (Git sets GIT_COMMIT_MSG in some environments, or we create a temp file)
if not commit_msg_file:
msg = os.getenv("GIT_COMMIT_MSG") or ""
if not msg:
print("⚠️ No commit message file or environment message found. Skipping Commitizen check.")
return 0
import tempfile
with tempfile.NamedTemporaryFile("w+", delete=False) as tmp:
tmp.write(msg)
tmp.flush()
commit_msg_file = tmp.name
print(f"🔍 Checking commit message using {cz}...")
try:
subprocess.check_call([cz, "check", "--commit-msg-file", commit_msg_file])
print("✅ Commit message follows Commitizen convention.")
return 0
except subprocess.CalledProcessError:
print("❌ Commit message validation failed.")
return 1
finally:
# Clean up temp file if we created one
if 'tmp' in locals():
os.unlink(tmp.name)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""Pre-push hook: Commitizen check for *new commits only*.
Cross-platform + virtualenv-aware:
- Prefers activated virtual environment (VIRTUAL_ENV)
- Falls back to ./.venv if found
- Falls back to global cz otherwise
"""
import os
import subprocess
import sys
from pathlib import Path
def find_cz_executable() -> str:
"""Return path to Commitizen executable, preferring virtual environments."""
# 1⃣ Active virtual environment (if running inside one)
venv_env = os.getenv("VIRTUAL_ENV")
if venv_env:
cz_path = Path(venv_env) / ("Scripts" if os.name == "nt" else "bin") / ("cz.exe" if os.name == "nt" else "cz")
if cz_path.exists():
return str(cz_path)
# 2⃣ Local .venv in repo root
repo_venv = Path.cwd() / ".venv"
cz_path = repo_venv / ("Scripts" if os.name == "nt" else "bin") / ("cz.exe" if os.name == "nt" else "cz")
if cz_path.exists():
return str(cz_path)
# 3⃣ Global fallback
return "cz"
def get_merge_base() -> str | None:
"""Return merge-base between HEAD and upstream branch, or None if unavailable."""
try:
return (
subprocess.check_output(
["git", "merge-base", "@{u}", "HEAD"],
stderr=subprocess.DEVNULL,
text=True,
)
.strip()
)
except subprocess.CalledProcessError:
return None
def main() -> int:
cz = find_cz_executable()
base = get_merge_base()
if not base:
print("⚠️ No upstream found; skipping Commitizen check for new commits.")
return 0
print(f"🔍 Using {cz} to check new commits from {base}..HEAD ...")
try:
subprocess.check_call([cz, "check", "--rev-range", f"{base}..HEAD"])
print("✅ All new commits follow Commitizen conventions.")
return 0
except subprocess.CalledProcessError as e:
print("❌ Commitizen check failed for one or more new commits.")
return e.returncode
if __name__ == "__main__":
sys.exit(main())

View File

@@ -4,19 +4,21 @@
import argparse
import json
import os
import re
import sys
import textwrap
from pathlib import Path
from typing import Any, Optional, Type, Union, get_args
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.datetimeutil import to_datetime
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()
@@ -24,29 +26,13 @@ undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
global_config_dict: dict[str, Any] = dict()
def get_model_class_from_annotation(field_type: Any) -> type[PydanticBaseModel] | None:
"""Given a type annotation (possibly Optional or Union), return the first Pydantic model class."""
origin = getattr(field_type, "__origin__", None)
if origin is Union:
# unwrap Union/Optional
for arg in get_args(field_type):
cls = get_model_class_from_annotation(arg)
if cls is not None:
return cls
return None
elif isinstance(field_type, type) and issubclass(field_type, PydanticBaseModel):
return field_type
else:
return None
def get_title(config: type[PydanticBaseModel]) -> str:
def get_title(config: PydanticBaseModel) -> str:
if config.__doc__ is None:
raise NameError(f"Missing docstring: {config}")
return config.__doc__.strip().splitlines()[0].strip(".")
def get_body(config: type[PydanticBaseModel]) -> str:
def get_body(config: PydanticBaseModel) -> str:
if config.__doc__ is None:
raise NameError(f"Missing docstring: {config}")
return textwrap.dedent("\n".join(config.__doc__.strip().splitlines()[1:])).strip()
@@ -68,79 +54,8 @@ def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple
return resolved_types
def get_example_or_default(field_name: str, field_info: FieldInfo, example_ix: int) -> Any:
"""Generate a default value for a field, considering constraints.
Priority:
1. field_info.examples
2. field_info.example
3. json_schema_extra['examples']
4. json_schema_extra['example']
5. field_info.default
"""
# 1. Old-style examples attribute
examples = getattr(field_info, "examples", None)
if examples is not None:
try:
return examples[example_ix]
except IndexError:
return examples[-1]
# 2. Old-style single example
example = getattr(field_info, "example", None)
if example is not None:
return example
# 3. Look into json_schema_extra (new style)
extra = getattr(field_info, "json_schema_extra", {}) or {}
examples = extra.get("examples")
if examples is not None:
try:
return examples[example_ix]
except IndexError:
return examples[-1]
example = extra.get("example")
if example is not None:
return example
# 5. Default
if getattr(field_info, "default", None) not in (None, ...):
return field_info.default
raise NotImplementedError(
f"No default or example provided for field '{field_name}': {field_info}"
)
def get_model_structure_from_examples(
model_class: type[PydanticBaseModel], multiple: bool
) -> list[dict[str, Any]]:
"""Create a model instance with default or example values, respecting constraints."""
example_max_length = 1
# Get first field with examples (non-default) to get example_max_length
if multiple:
for _, field_info in model_class.model_fields.items():
if field_info.examples is not None:
example_max_length = len(field_info.examples)
break
example_data: list[dict[str, Any]] = [{} for _ in range(example_max_length)]
for field_name, field_info in model_class.model_fields.items():
if field_info.deprecated:
continue
for example_ix in range(example_max_length):
example_data[example_ix][field_name] = get_example_or_default(
field_name, field_info, example_ix
)
return example_data
def create_model_from_examples(
model_class: type[PydanticBaseModel], multiple: bool
model_class: PydanticBaseModel, multiple: bool
) -> list[PydanticBaseModel]:
"""Create a model instance with default or example values, respecting constraints."""
return [
@@ -179,7 +94,7 @@ def get_type_name(field_type: type) -> str:
def generate_config_table_md(
config: type[PydanticBaseModel],
config: PydanticBaseModel,
toplevel_keys: list[str],
prefix: str,
toplevel: bool = False,
@@ -215,28 +130,21 @@ def generate_config_table_md(
table += "\n\n"
table += (
"<!-- pyml disable line-length -->\n"
":::{table} "
+ f"{'::'.join(toplevel_keys)}\n:widths: 10 {env_width}10 5 5 30\n:align: left\n\n"
)
table += f"| Name {env_header}| Type | Read-Only | Default | Description |\n"
table += f"| ---- {env_header_underline}| ---- | --------- | ------- | ----------- |\n"
fields = {}
for field_name, field_info in config.model_fields.items():
fields[field_name] = field_info
for field_name, field_info in config.model_computed_fields.items():
fields[field_name] = field_info
for field_name in sorted(fields.keys()):
field_info = fields[field_name]
for field_name, field_info in list(config.model_fields.items()) + list(
config.model_computed_fields.items()
):
regular_field = isinstance(field_info, FieldInfo)
config_name = field_name if extra_config else field_name.upper()
field_type = field_info.annotation if regular_field else field_info.return_type
default_value = get_default_value(field_info, regular_field)
description = config.field_description(field_name)
deprecated = config.field_deprecated(field_name)
description = field_info.description if field_info.description else "-"
read_only = "rw" if regular_field else "ro"
type_name = get_type_name(field_type)
@@ -246,34 +154,25 @@ 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[type[PydanticBaseModel], tuple[str, list[str]]] = dict()
inner_types: dict[Any, tuple[str, list[str]]] = dict()
inner_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
def extract_nested_models(subtype: Any, subprefix: str, parent_types: list[str]):
"""Extract nested models."""
if subtype in inner_types.keys():
return
nested_types = resolve_nested_types(subtype, [])
for nested_type, nested_parent_types in nested_types:
# Nested type may be of type class, enum, typing.Any
if isinstance(nested_type, type) and issubclass(nested_type, PydanticBaseModel):
# Nested type is a subclass of PydanticBaseModel
if issubclass(nested_type, PydanticBaseModel):
new_parent_types = parent_types + nested_parent_types
if "list" in parent_types:
new_prefix = ""
else:
new_prefix = f"{subprefix}"
inner_types.setdefault(nested_type, (new_prefix, new_parent_types))
# Handle normal fields
for nested_field_name, nested_field_info in nested_type.model_fields.items():
for nested_field_name, nested_field_info in list(
nested_type.model_fields.items()
) + list(nested_type.model_computed_fields.items()):
nested_field_type = nested_field_info.annotation
if new_prefix:
new_prefix += f"{nested_field_name.upper()}__"
@@ -283,8 +182,6 @@ def generate_config_table_md(
new_parent_types + [nested_field_name],
)
# Do not extract computed fields
extract_nested_models(field_type, f"{prefix}{config_name}__", toplevel_keys + [field_name])
for new_type, info in inner_types.items():
@@ -292,7 +189,7 @@ def generate_config_table_md(
undocumented_types.setdefault(new_type, (info[0], info[1]))
if toplevel:
table += ":::\n<!-- pyml enable line-length -->\n\n" # Add an empty line after the table
table += ":::\n\n" # Add an empty line after the table
has_examples_list = toplevel_keys[-1] == "list"
instance_list = create_model_from_examples(config, has_examples_list)
@@ -310,13 +207,9 @@ def generate_config_table_md(
same_output = ins_out_dict_list == ins_dict_list
same_output_str = "/Output" if same_output else ""
# -- code block heading
table += "<!-- pyml disable no-emphasis-as-heading -->\n"
table += f"**Example Input{same_output_str}**\n"
table += "<!-- pyml enable no-emphasis-as-heading -->\n\n"
# -- code block
table += "<!-- pyml disable line-length -->\n"
table += "```json\n"
table += f"#{heading_level} Example Input{same_output_str}\n\n"
table += "```{eval-rst}\n"
table += ".. code-block:: json\n\n"
if has_examples_list:
input_dict = build_nested_structure(toplevel_keys[:-1], ins_dict_list)
if not extra_config:
@@ -326,24 +219,20 @@ def generate_config_table_md(
if not extra_config:
global_config_dict[toplevel_keys[0]] = ins_dict_list[0]
table += textwrap.indent(json.dumps(input_dict, indent=4), " ")
table += "\n```\n<!-- pyml enable line-length -->\n\n"
# -- end code block
table += "\n"
table += "```\n\n"
if not same_output:
# -- code block heading
table += "<!-- pyml disable no-emphasis-as-heading -->\n"
table += f"**Example Output**\n"
table += "<!-- pyml enable no-emphasis-as-heading -->\n\n"
# -- code block
table += "<!-- pyml disable line-length -->\n"
table += "```json\n"
table += f"#{heading_level} Example Output\n\n"
table += "```{eval-rst}\n"
table += ".. code-block:: json\n\n"
if has_examples_list:
output_dict = build_nested_structure(toplevel_keys[:-1], ins_out_dict_list)
else:
output_dict = build_nested_structure(toplevel_keys, ins_out_dict_list[0])
table += textwrap.indent(json.dumps(output_dict, indent=4), " ")
table += "\n```\n<!-- pyml enable line-length -->\n\n"
# -- end code block
table += "\n"
table += "```\n\n"
while undocumented_types:
extra_config_type, extra_info = undocumented_types.popitem()
@@ -355,7 +244,7 @@ def generate_config_table_md(
return table
def generate_config_md(file_path: Optional[Union[str, Path]], config_eos: ConfigEOS) -> str:
def generate_config_md(config_eos: ConfigEOS) -> str:
"""Generate configuration specification in Markdown with extra tables for prefixed values.
Returns:
@@ -367,105 +256,32 @@ def generate_config_md(file_path: Optional[Union[str, Path]], config_eos: Config
)
GeneralSettings._config_folder_path = config_eos.general.config_file_path.parent
markdown = ""
if file_path:
file_path = Path(file_path)
# -- table of content
markdown += "```{toctree}\n"
markdown += ":maxdepth: 1\n"
markdown += ":caption: Configuration Table\n\n"
else:
markdown += "# Configuration Table\n\n"
markdown += (
"The configuration table describes all the configuration options of Akkudoktor-EOS\n\n"
)
markdown = "# Configuration Table\n\n"
# Generate tables for each top level config
for field_name in sorted(config_eos.__class__.model_fields.keys()):
field_info = config_eos.__class__.model_fields[field_name]
for field_name, field_info in config_eos.model_fields.items():
field_type = field_info.annotation
model_class = get_model_class_from_annotation(field_type)
if model_class is None:
raise ValueError(f"Can not find class of top level field {field_name}.")
table = generate_config_table_md(
model_class, [field_name], f"EOS_{field_name.upper()}__", True
markdown += generate_config_table_md(
field_type, [field_name], f"EOS_{field_name.upper()}__", True
)
if file_path:
# Write table to extra document
table_path = file_path.with_name(file_path.stem + f"{field_name.lower()}.md")
write_to_file(table_path, table)
markdown += f"../_generated/{table_path.name}\n"
else:
# We will write to stdout
markdown += "---\n\n"
markdown += table
# Generate full example
example = ""
# Full config
example += "## Full example Config\n\n"
# -- code block
example += "<!-- pyml disable line-length -->\n"
example += "```json\n"
markdown += "## Full example Config\n\n"
markdown += "```{eval-rst}\n"
markdown += ".. code-block:: json\n\n"
# Test for valid config first
config_eos.merge_settings_from_dict(global_config_dict)
example += textwrap.indent(json.dumps(global_config_dict, indent=4), " ")
example += "\n"
example += "```\n<!-- pyml enable line-length -->\n\n"
# -- end code block end
if file_path:
example_path = file_path.with_name(file_path.stem + f"example.md")
write_to_file(example_path, example)
markdown += f"../_generated/{example_path.name}\n"
markdown += textwrap.indent(json.dumps(global_config_dict, indent=4), " ")
markdown += "\n"
markdown += "```\n\n"
# -- end table of content
else:
markdown += "---\n\n"
markdown += example
# Assure there is no double \n at end of file
markdown = markdown.rstrip("\n")
markdown += "\n"
markdown += "\nAuto generated from source code.\n"
# Write markdown to file or stdout
write_to_file(file_path, markdown)
return markdown
def write_to_file(file_path: Optional[Union[str, Path]], config_md: str):
if os.name == "nt":
config_md = config_md.replace("\\\\", "/")
# Assure log path does not leak to documentation
config_md = re.sub(
r'(?<=["\'])/[^"\']*/output/eos\.log(?=["\'])',
'/home/user/.local/share/net.akkudoktor.eos/output/eos.log',
config_md
)
# Assure timezone name does not leak to documentation
tz_name = to_datetime().timezone_name
config_md = re.sub(re.escape(tz_name), "Europe/Berlin", config_md, flags=re.IGNORECASE)
# Also replace UTC, as GitHub CI always is on UTC
config_md = re.sub(re.escape("UTC"), "Europe/Berlin", config_md, flags=re.IGNORECASE)
# Assure no extra lines at end of file
config_md = config_md.rstrip("\n")
config_md += "\n"
if file_path:
# Write to file
with open(Path(file_path), "w", encoding="utf-8", newline="\n") as f:
f.write(config_md)
else:
# Write to std output
print(config_md)
def main():
"""Main function to run the generation of the Configuration specification as Markdown."""
parser = argparse.ArgumentParser(description="Generate Configuration Specification as Markdown")
@@ -473,14 +289,23 @@ def main():
"--output-file",
type=str,
default=None,
help="File to write the top level configuration specification to.",
help="File to write the Configuration Specification to",
)
args = parser.parse_args()
config_eos = get_config()
try:
config_md = generate_config_md(args.output_file, config_eos)
config_md = generate_config_md(config_eos)
if os.name == "nt":
config_md = config_md.replace("127.0.0.1", "0.0.0.0").replace("\\\\", "/")
if args.output_file:
# Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
f.write(config_md)
else:
# Write to std output
print(config_md)
except Exception as e:
print(f"Error during Configuration Specification generation: {e}", file=sys.stderr)

View File

@@ -42,9 +42,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
@@ -61,6 +58,8 @@ def main():
try:
openapi_spec = generate_openapi()
openapi_spec_str = json.dumps(openapi_spec, indent=2)
if os.name == "nt":
openapi_spec_str = openapi_spec_str.replace("127.0.0.1", "0.0.0.0")
if args.output_file:
# Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:

View File

@@ -194,8 +194,6 @@ def format_endpoint(path: str, method: str, details: dict, devel: bool = False)
markdown = f"## {method.upper()} {path}\n\n"
# -- links
markdown += "<!-- pyml disable line-length -->\n"
markdown += f"**Links**: {local_path}, {akkudoktoreos_main_path}"
if devel:
# Add link to akkudoktor branch the development has used
@@ -208,8 +206,7 @@ def format_endpoint(path: str, method: str, details: dict, devel: bool = False)
+ link_method
)
markdown += f", {akkudoktoreos_base_path}"
markdown += "\n<!-- pyml enable line-length -->\n\n"
# -- links end
markdown += "\n\n"
summary = details.get("summary", None)
if summary:
@@ -217,14 +214,9 @@ def format_endpoint(path: str, method: str, details: dict, devel: bool = False)
description = details.get("description", None)
if description:
# -- code block
markdown += "<!-- pyml disable line-length -->\n"
markdown += "```python\n"
markdown += '"""\n'
markdown += f"{description}\n"
markdown += '"""\n'
markdown += "```\n<!-- pyml enable line-length -->\n\n"
# -- end code block end
markdown += "```\n"
markdown += f"{description}"
markdown += "\n```\n\n"
markdown += format_parameters(details.get("parameters", []))
markdown += format_request_body(details.get("requestBody", {}).get("content", {}))
@@ -247,11 +239,7 @@ def openapi_to_markdown(openapi_json: dict, devel: bool = False) -> str:
info = extract_info(openapi_json)
markdown = f"# {info['title']}\n\n"
markdown += f"**Version**: `{info['version']}`\n\n"
# -- description
markdown += "<!-- pyml disable line-length -->\n"
markdown += f"**Description**: {info['description']}\n"
markdown += "<!-- pyml enable line-length -->\n\n"
# -- end description
markdown += f"**Description**: {info['description']}\n\n"
markdown += f"**Base URL**: `{info['base_url']}`\n\n"
security_schemes = openapi_json.get("components", {}).get("securitySchemes", {})
@@ -269,8 +257,6 @@ def openapi_to_markdown(openapi_json: dict, devel: bool = False) -> str:
markdown = markdown.rstrip("\n")
markdown += "\n"
markdown += "\nAuto generated from openapi.json.\n"
return markdown
@@ -300,7 +286,7 @@ 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")
openapi_md = openapi_md.replace("127.0.0.1", "0.0.0.0")
if args.output_file:
# Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:

View File

@@ -1,15 +0,0 @@
#!.venv/bin/python
"""Get version of EOS"""
import sys
from pathlib import Path
# Add the src directory to sys.path so Sphinx can import akkudoktoreos
PROJECT_ROOT = Path(__file__).parent.parent
SRC_DIR = PROJECT_ROOT / "src"
sys.path.insert(0, str(SRC_DIR))
from akkudoktoreos.core.version import __version__
if __name__ == "__main__":
print(__version__)

View File

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

View File

@@ -1,113 +0,0 @@
#!.venv/bin/python
"""General version replacement script.
Usage:
python scripts/update_version.py <version> <file1> [file2 ...]
"""
#!/usr/bin/env python3
import re
import sys
from pathlib import Path
from typing import List
# --- Patterns to match version strings ---
VERSION_PATTERNS = [
# Python: __version__ = "1.2.3"
re.compile(
r'(?<![A-Za-z0-9])(__version__\s*=\s*")'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
r'(")'
),
# Python: version = "1.2.3"
re.compile(
r'(?<![A-Za-z0-9])(version\s*=\s*")'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
r'(")'
),
# JSON: "version": "1.2.3"
re.compile(
r'(?<![A-Za-z0-9])("version"\s*:\s*")'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
r'(")'
),
# Makefile-style: VERSION ?= 1.2.3
re.compile(
r'(?<![A-Za-z0-9])(VERSION\s*\?=\s*)'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
),
# YAML: version: "1.2.3"
re.compile(
r'(?m)^(version\s*:\s*["\']?)'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
r'(["\']?)\s*$'
),
]
def update_version_in_file(file_path: Path, new_version: str) -> bool:
"""
Replace version strings in a file based on VERSION_PATTERNS.
Returns True if the file was updated.
"""
content = file_path.read_text()
new_content = content
file_would_be_updated = False
for pattern in VERSION_PATTERNS:
def repl(match):
nonlocal file_would_be_updated
ver = match.group("ver")
if ver != new_version:
file_would_be_updated = True
# Three-group patterns (__version__, JSON, YAML)
if len(match.groups()) == 3:
return f"{match.group(1)}{new_version}{match.group(3)}"
# Two-group patterns (Makefile)
return f"{match.group(1)}{new_version}"
return match.group(0)
new_content = pattern.sub(repl, new_content)
if file_would_be_updated:
file_path.write_text(new_content)
return file_would_be_updated
def main(version: str, files: List[str]):
if not version:
raise ValueError("No version provided")
if not files:
raise ValueError("No files provided")
updated_files = []
for f in files:
path = Path(f)
if not path.exists():
print(f"Warning: {path} does not exist, skipping")
continue
if update_version_in_file(path, version):
updated_files.append(str(path))
if updated_files:
print(f"Updated files: {', '.join(updated_files)}")
else:
print("No files updated.")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python update_version.py <version> <file1> [file2 ...]")
sys.exit(1)
version_arg = sys.argv[1]
files_arg = sys.argv[2:]
main(version_arg, files_arg)

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env python3
import argparse
import asyncio
import cProfile
import json
import pstats
@@ -10,27 +9,24 @@ import time
from typing import Any
import numpy as np
from loguru import logger
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.core.emsettings import EnergyManagementMode
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.optimization.genetic import (
OptimizationParameters,
optimization_problem,
)
from akkudoktoreos.prediction.prediction import get_prediction
from akkudoktoreos.utils.datetimeutil import to_datetime
config_eos = get_config()
prediction_eos = get_prediction()
ems_eos = get_ems()
get_logger(__name__, logging_level="DEBUG")
def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
def prepare_optimization_real_parameters() -> OptimizationParameters:
"""Prepare and return optimization parameters with real world data.
Returns:
GeneticOptimizationParameters: Configured optimization parameters
OptimizationParameters: Configured optimization parameters
"""
# Make a config
settings = {
@@ -42,18 +38,6 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
"hours": 48,
"historic_hours": 24,
},
"optimization": {
"horizon_hours": 24,
"interval": 3600,
"genetic": {
"individuals": 300,
"generations": 400,
"seed": None,
"penalties": {
"ev_soc_miss": 10,
},
},
},
# PV Forecast
"pvforecast": {
"provider": "PVForecastAkkudoktor",
@@ -100,30 +84,14 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
"load": {
"provider": "LoadAkkudoktor",
"provider_settings": {
"LoadAkkudoktor": {
"loadakkudoktor_year_energy_kwh": 5000, # Energy consumption per year in kWh
},
"loadakkudoktor_year_energy": 5000, # Energy consumption per year in kWh
},
},
# -- Simulations --
# Assure we have charge rates for the EV
"devices": {
"max_electric_vehicles": 1,
"electric_vehicles": [
{
"charge_rates": [
0.0,
6.0 / 16.0,
8.0 / 16.0,
10.0 / 16.0,
12.0 / 16.0,
14.0 / 16.0,
1.0,
],
},
],
},
}
config_eos = get_config()
prediction_eos = get_prediction()
ems_eos = get_ems()
# Update/ set configuration
config_eos.merge_settings_from_dict(settings)
@@ -131,14 +99,14 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
# Get current prediction data for optimization run
ems_eos.set_start_datetime()
print(
f"Real data prediction from {prediction_eos.ems_start_datetime} to {prediction_eos.end_datetime}"
f"Real data prediction from {prediction_eos.start_datetime} to {prediction_eos.end_datetime}"
)
prediction_eos.update_data()
# PV Forecast (in W)
pv_forecast = prediction_eos.key_to_array(
key="pvforecast_ac_power",
start_datetime=prediction_eos.ems_start_datetime,
start_datetime=prediction_eos.start_datetime,
end_datetime=prediction_eos.end_datetime,
)
print(f"pv_forecast: {pv_forecast}")
@@ -146,7 +114,7 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
# Temperature Forecast (in degree C)
temperature_forecast = prediction_eos.key_to_array(
key="weather_temp_air",
start_datetime=prediction_eos.ems_start_datetime,
start_datetime=prediction_eos.start_datetime,
end_datetime=prediction_eos.end_datetime,
)
print(f"temperature_forecast: {temperature_forecast}")
@@ -154,7 +122,7 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
# Electricity Price (in Euro per Wh)
strompreis_euro_pro_wh = prediction_eos.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=prediction_eos.ems_start_datetime,
start_datetime=prediction_eos.start_datetime,
end_datetime=prediction_eos.end_datetime,
)
print(f"strompreis_euro_pro_wh: {strompreis_euro_pro_wh}")
@@ -162,7 +130,7 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
# Overall System Load (in W)
gesamtlast = prediction_eos.key_to_array(
key="load_mean",
start_datetime=prediction_eos.ems_start_datetime,
start_datetime=prediction_eos.start_datetime,
end_datetime=prediction_eos.end_datetime,
)
print(f"gesamtlast: {gesamtlast}")
@@ -172,7 +140,7 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
print(f"start_solution: {start_solution}")
# Define parameters for the optimization problem
return GeneticOptimizationParameters(
return OptimizationParameters(
**{
"ems": {
"preis_euro_pro_wh_akku": 0e-05,
@@ -187,13 +155,9 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
"initial_soc_percentage": 15,
"min_soc_percentage": 15,
},
"inverter": {
"device_id": "inverter 1",
"max_power_wh": 10000,
"battery_id": "battery 1",
},
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery_id": "battery1"},
"eauto": {
"device_id": "electric vehicle 1",
"device_id": "ev1",
"min_soc_percentage": 50,
"capacity_wh": 60000,
"charging_efficiency": 0.95,
@@ -206,49 +170,12 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
)
def prepare_optimization_parameters() -> GeneticOptimizationParameters:
def prepare_optimization_parameters() -> OptimizationParameters:
"""Prepare and return optimization parameters with predefined data.
Returns:
GeneticOptimizationParameters: Configured optimization parameters
OptimizationParameters: Configured optimization parameters
"""
# Initialize the optimization problem using the default configuration
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {
"horizon_hours": 48,
"interval": 3600,
"genetic": {
"individuals": 300,
"generations": 400,
"seed": None,
"penalties": {
"ev_soc_miss": 10,
},
},
},
# Assure we have charge rates for the EV
"devices": {
"max_electric_vehicles": 1,
"electric_vehicles": [
{
"device_id": "Default EV",
"charge_rates": [
0.0,
6.0 / 16.0,
8.0 / 16.0,
10.0 / 16.0,
12.0 / 16.0,
14.0 / 16.0,
1.0,
],
},
],
},
}
)
# PV Forecast (in W)
pv_forecast = np.zeros(48)
pv_forecast[12] = 5000
@@ -367,7 +294,7 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
start_solution = None
# Define parameters for the optimization problem
return GeneticOptimizationParameters(
return OptimizationParameters(
**{
"ems": {
"preis_euro_pro_wh_akku": 0e-05,
@@ -382,13 +309,9 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
"initial_soc_percentage": 15,
"min_soc_percentage": 15,
},
"inverter": {
"device_id": "inverter 1",
"max_power_wh": 10000,
"battery_id": "battery 1",
},
"inverter": {"device_id": "iv1", "max_power_wh": 10000, "battery_id": "battery1"},
"eauto": {
"device_id": "electric vehicle 1",
"device_id": "ev1",
"min_soc_percentage": 50,
"capacity_wh": 60000,
"charging_efficiency": 0.95,
@@ -416,33 +339,27 @@ def run_optimization(
# Prepare parameters
if parameters_file:
with open(parameters_file, "r") as f:
parameters = GeneticOptimizationParameters(**json.load(f))
parameters = OptimizationParameters(**json.load(f))
elif real_world:
parameters = prepare_optimization_real_parameters()
else:
parameters = prepare_optimization_parameters()
logger.info("Optimization Parameters:")
logger.info(parameters.model_dump_json(indent=4))
if start_hour is None:
start_datetime = None
else:
start_datetime = to_datetime().set(hour=start_hour)
if verbose:
print("\nOptimization Parameters:")
print(parameters.model_dump_json(indent=4))
asyncio.run(
ems_eos.run(
start_datetime=start_datetime,
mode=EnergyManagementMode.OPTIMIZATION,
genetic_parameters=parameters,
genetic_individuals=ngen,
genetic_seed=seed,
)
# Initialize the optimization problem using the default configuration
config_eos = get_config()
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"hours": 48}}
)
opt_class = optimization_problem(verbose=verbose, fixed_seed=seed)
solution = ems_eos.genetic_solution()
if solution is None:
return None
return solution.model_dump_json()
# Perform the optimisation based on the provided parameters and start hour
result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour, ngen=ngen)
return result.model_dump_json()
def main():

View File

@@ -93,22 +93,6 @@ def config_elecprice() -> dict:
return settings
def config_feedintarifffixed() -> dict:
"""Configure settings for feed in tariff forecast."""
settings = {
"general": {
"latitude": 52.52,
"longitude": 13.405,
},
"prediction": {
"hours": 48,
"historic_hours": 24,
},
"feedintariff": dict(),
}
return settings
def config_load() -> dict:
"""Configure settings for load forecast."""
settings = {
@@ -146,13 +130,10 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
elif provider_id in ("ElecPriceAkkudoktor",):
settings = config_elecprice()
forecast = "elecprice"
elif provider_id in ("FeedInTariffFixed",):
settings = config_feedintarifffixed()
forecast = "feedintariff"
elif provider_id in ("LoadAkkudoktor",):
settings = config_load()
forecast = "loadforecast"
settings["load"]["LoadAkkudoktor"]["loadakkudoktor_year_energy_wh"] = 1000
settings = config_elecprice()
forecast = "load"
settings["load"]["loadakkudoktor_year_energy"] = 1000
else:
raise ValueError(f"Unknown provider '{provider_id}'.")
settings[forecast]["provider"] = provider_id
@@ -170,7 +151,6 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
print(settings)
print("\nProvider\n----------")
print(f"elecprice.provider: {config_eos.elecprice.provider}")
print(f"feedintariff.provider: {config_eos.feedintariff.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}")

View File

@@ -9,42 +9,43 @@ Key features:
- Managing directory setups for the application
"""
import json
import os
import tempfile
import shutil
from pathlib import Path
from typing import Any, ClassVar, Optional, Type
import pydantic_settings
from loguru import logger
from platformdirs import user_config_dir, user_data_dir
from pydantic import Field, computed_field, field_validator
from pydantic import Field, computed_field
from pydantic_settings import (
BaseSettings,
JsonConfigSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
# settings
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.config.configmigrate import migrate_config_data, migrate_config_file
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.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.version import __version__
from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.core.pydantic import access_nested_value, merge_models
from akkudoktoreos.devices.settings import DevicesCommonSettings
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.feedintariff import FeedInTariffCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.prediction import PredictionCommonSettings
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.prediction.weather import WeatherCommonSettings
from akkudoktoreos.server.server import ServerCommonSettings
from akkudoktoreos.utils.datetimeutil import to_datetime, to_timezone
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]
@@ -79,44 +80,34 @@ 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
_config_file_path: ClassVar[Optional[Path]] = None
version: str = Field(
default=__version__,
json_schema_extra={
"description": "Configuration file version. Used to check compatibility."
},
)
data_folder_path: Optional[Path] = Field(
default=None,
json_schema_extra={
"description": "Path to EOS data directory.",
"examples": [None, "/home/eos/data"],
},
default=None, description="Path to EOS data directory.", examples=[None, "/home/eos/data"]
)
data_output_subpath: Optional[Path] = Field(
default="output",
json_schema_extra={"description": "Sub-path for the EOS output data directory."},
default="output", description="Sub-path for the EOS output data directory."
)
latitude: Optional[float] = Field(
default=52.52,
ge=-90.0,
le=90.0,
json_schema_extra={
"description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)"
},
description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
)
longitude: Optional[float] = Field(
default=13.405,
ge=-180.0,
le=180.0,
json_schema_extra={"description": "Longitude in decimal degrees, within -180 to 180 (°)"},
description="Longitude in decimal degrees, within -180 to 180 (°)",
)
# Computed fields
@@ -146,74 +137,71 @@ class GeneralSettings(SettingsBaseModel):
"""Path to EOS configuration file."""
return self._config_file_path
compatible_versions: ClassVar[list[str]] = [__version__]
@field_validator("version")
@classmethod
def check_version(cls, v: str) -> str:
if v not in cls.compatible_versions:
error = (
f"Incompatible configuration version '{v}'. "
f"Expected: {', '.join(cls.compatible_versions)}."
)
logger.error(error)
raise ValueError(error)
return v
class SettingsEOS(pydantic_settings.BaseSettings, PydanticModelNestedValueMixin):
class SettingsEOS(BaseSettings):
"""Settings for all EOS.
Only used to update the configuration with specific settings.
Used by updating the configuration with specific settings only.
"""
general: Optional[GeneralSettings] = Field(
default=None, json_schema_extra={"description": "General Settings"}
default=None,
description="General Settings",
)
cache: Optional[CacheCommonSettings] = Field(
default=None, json_schema_extra={"description": "Cache Settings"}
default=None,
description="Cache Settings",
)
ems: Optional[EnergyManagementCommonSettings] = Field(
default=None, json_schema_extra={"description": "Energy Management Settings"}
default=None,
description="Energy Management Settings",
)
logging: Optional[LoggingCommonSettings] = Field(
default=None, json_schema_extra={"description": "Logging Settings"}
default=None,
description="Logging Settings",
)
devices: Optional[DevicesCommonSettings] = Field(
default=None, json_schema_extra={"description": "Devices Settings"}
default=None,
description="Devices Settings",
)
measurement: Optional[MeasurementCommonSettings] = Field(
default=None, json_schema_extra={"description": "Measurement Settings"}
default=None,
description="Measurement Settings",
)
optimization: Optional[OptimizationCommonSettings] = Field(
default=None, json_schema_extra={"description": "Optimization Settings"}
default=None,
description="Optimization Settings",
)
prediction: Optional[PredictionCommonSettings] = Field(
default=None, json_schema_extra={"description": "Prediction Settings"}
default=None,
description="Prediction Settings",
)
elecprice: Optional[ElecPriceCommonSettings] = Field(
default=None, json_schema_extra={"description": "Electricity Price Settings"}
)
feedintariff: Optional[FeedInTariffCommonSettings] = Field(
default=None, json_schema_extra={"description": "Feed In Tariff Settings"}
default=None,
description="Electricity Price Settings",
)
load: Optional[LoadCommonSettings] = Field(
default=None, json_schema_extra={"description": "Load Settings"}
default=None,
description="Load Settings",
)
pvforecast: Optional[PVForecastCommonSettings] = Field(
default=None, json_schema_extra={"description": "PV Forecast Settings"}
default=None,
description="PV Forecast Settings",
)
weather: Optional[WeatherCommonSettings] = Field(
default=None, json_schema_extra={"description": "Weather Settings"}
default=None,
description="Weather Settings",
)
server: Optional[ServerCommonSettings] = Field(
default=None, json_schema_extra={"description": "Server Settings"}
default=None,
description="Server Settings",
)
utils: Optional[UtilsCommonSettings] = Field(
default=None, json_schema_extra={"description": "Utilities Settings"}
default=None,
description="Utilities Settings",
)
model_config = pydantic_settings.SettingsConfigDict(
model_config = SettingsConfigDict(
env_nested_delimiter="__",
nested_model_default_partial_update=True,
env_prefix="EOS_",
@@ -236,18 +224,12 @@ class SettingsEOSDefaults(SettingsEOS):
optimization: OptimizationCommonSettings = OptimizationCommonSettings()
prediction: PredictionCommonSettings = PredictionCommonSettings()
elecprice: ElecPriceCommonSettings = ElecPriceCommonSettings()
feedintariff: FeedInTariffCommonSettings = FeedInTariffCommonSettings()
load: LoadCommonSettings = LoadCommonSettings()
pvforecast: PVForecastCommonSettings = PVForecastCommonSettings()
weather: WeatherCommonSettings = WeatherCommonSettings()
server: ServerCommonSettings = ServerCommonSettings()
utils: UtilsCommonSettings = UtilsCommonSettings()
def __hash__(self) -> int:
# Just for usage in configmigrate, finally overwritten when used by ConfigEOS.
# This is mutable, so pydantic does not set a hash.
return id(self)
class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""Singleton configuration handler for the EOS application.
@@ -287,10 +269,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
Example:
To initialize and access configuration attributes (only one instance is created):
.. code-block:: python
```python
config_eos = ConfigEOS() # Always returns the same instance
print(config_eos.prediction.hours) # Access a setting from the loaded configuration
```
"""
@@ -301,106 +283,82 @@ 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,
settings_cls: Type[pydantic_settings.BaseSettings],
init_settings: pydantic_settings.PydanticBaseSettingsSource,
env_settings: pydantic_settings.PydanticBaseSettingsSource,
dotenv_settings: pydantic_settings.PydanticBaseSettingsSource,
file_secret_settings: pydantic_settings.PydanticBaseSettingsSource,
) -> tuple[pydantic_settings.PydanticBaseSettingsSource, ...]:
"""Customizes the order and handling of settings sources for a pydantic_settings.BaseSettings subclass.
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
"""Customizes the order and handling of settings sources for a Pydantic BaseSettings subclass.
This method determines the sources for application configuration settings, including
environment variables, dotenv files and JSON configuration files.
It ensures that a default configuration file exists and creates one if necessary.
Args:
settings_cls (Type[pydantic_settings.BaseSettings]): The Pydantic BaseSettings class for
which sources are customized.
init_settings (pydantic_settings.PydanticBaseSettingsSource): The initial settings source, typically passed at runtime.
env_settings (pydantic_settings.PydanticBaseSettingsSource): Settings sourced from environment variables.
dotenv_settings (pydantic_settings.PydanticBaseSettingsSource): Settings sourced from a dotenv file.
file_secret_settings (pydantic_settings.PydanticBaseSettingsSource): Unused (needed for parent class interface).
settings_cls (Type[BaseSettings]): The Pydantic BaseSettings class for which sources are customized.
init_settings (PydanticBaseSettingsSource): The initial settings source, typically passed at runtime.
env_settings (PydanticBaseSettingsSource): Settings sourced from environment variables.
dotenv_settings (PydanticBaseSettingsSource): Settings sourced from a dotenv file.
file_secret_settings (PydanticBaseSettingsSource): Unused (needed for parent class interface).
Returns:
tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
Behavior:
1. Checks for the existence of a JSON configuration file in the expected location.
2. If the configuration file does not exist, creates the directory (if needed) and
attempts to create a default configuration file in the location. If the creation
fails, a temporary configuration directory is used.
3. Creates a `pydantic_settings.JsonConfigSettingsSource` for the configuration
file.
2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a
default configuration file to the location. If the copy fails, uses the default configuration file directly.
3. Creates a `JsonConfigSettingsSource` for both the configuration file and the default configuration file.
4. Updates class attributes `GeneralSettings._config_folder_path` and
`GeneralSettings._config_file_path` to reflect the determined paths.
5. Returns a tuple containing all provided and newly created settings sources in
the desired order.
5. Returns a tuple containing all provided and newly created settings sources in the desired order.
Notes:
- This method logs an error if the default configuration file in the normal
configuration directory cannot be created.
- It ensures that a fallback to a default configuration file is always possible.
- 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.
"""
# Ensure we know and have the config folder path and the config file
config_file, exists = cls._get_config_file_path()
config_dir = config_file.parent
if not exists:
config_dir.mkdir(parents=True, exist_ok=True)
# Create minimum config file
config_minimum_content = '{ "general": { "version": "' + __version__ + '" } }'
try:
config_file.write_text(config_minimum_content, encoding="utf-8")
except Exception as exc:
# Create minimum config in temporary config directory as last resort
error_msg = f"Could not create minimum config file in {config_dir}: {exc}"
logger.error(error_msg)
temp_dir = Path(tempfile.mkdtemp())
info_msg = f"Using temporary config directory {temp_dir}"
logger.info(info_msg)
config_dir = temp_dir
config_file = temp_dir / config_file.name
config_file.write_text(config_minimum_content, encoding="utf-8")
# Remember config_dir and config file
GeneralSettings._config_folder_path = config_dir
GeneralSettings._config_file_path = config_file
# All the settings sources in priority sequence
setting_sources = [
init_settings,
env_settings,
dotenv_settings,
]
# Apend file settings to sources
file_settings: Optional[pydantic_settings.JsonConfigSettingsSource] = None
file_settings: Optional[JsonConfigSettingsSource] = None
config_file, exists = cls._get_config_file_path()
config_dir = config_file.parent
if not exists:
config_dir.mkdir(parents=True, exist_ok=True)
try:
backup_file = config_file.with_suffix(f".{to_datetime(as_string='YYYYMMDDHHmmss')}")
if migrate_config_file(config_file, backup_file):
# If the config file does have the correct version add it as settings source
file_settings = pydantic_settings.JsonConfigSettingsSource(
settings_cls, json_file=config_file
)
shutil.copy2(cls.config_default_file_path, config_file)
except Exception as exc:
logger.warning(f"Could not copy default config: {exc}. Using default config...")
config_file = cls.config_default_file_path
config_dir = config_file.parent
try:
file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file)
setting_sources.append(file_settings)
except Exception as ex:
except Exception as e:
logger.error(
f"Error reading config file '{config_file}' (falling back to default config): {ex}"
f"Error reading config file '{config_file}' (falling back to default config): {e}"
)
default_settings = JsonConfigSettingsSource(
settings_cls, json_file=cls.config_default_file_path
)
GeneralSettings._config_folder_path = config_dir
GeneralSettings._config_file_path = config_file
setting_sources.append(default_settings)
return tuple(setting_sources)
@classproperty
def config_default_file_path(cls) -> Path:
"""Compute the default config file path."""
return cls.package_root_path.joinpath("data/default.config.json")
@classproperty
def package_root_path(cls) -> Path:
"""Compute the package root path."""
@@ -412,24 +370,19 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
Configuration data is loaded from a configuration file or a default one is created if none
exists.
"""
logger.debug("Config init with parameters {} {}", args, kwargs)
# Check for singleton guard
if hasattr(self, "_initialized"):
return
self._setup(self, *args, **kwargs)
def _setup(self, *args: Any, **kwargs: Any) -> None:
"""Re-initialize global settings."""
logger.debug("Config setup with parameters {} {}", args, kwargs)
# Assure settings base knows the singleton EOS configuration
# Assure settings base knows EOS configuration
SettingsBaseModel.config = self
# (Re-)load settings - call base class init
# (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()
self._initialized = True
logger.debug("Config setup:\n{}", self)
def merge_settings(self, settings: SettingsEOS) -> None:
"""Merges the provided settings into the global settings for EOS, with optional overwrite.
@@ -441,9 +394,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))
@@ -462,12 +413,9 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
ValidationError: If the data contains invalid values for the defined fields.
Example:
.. code-block:: python
config = get_config()
new_data = {"prediction": {"hours": 24}, "server": {"port": 8000}}
config.merge_settings_from_dict(new_data)
>>> config = get_config()
>>> new_data = {"prediction": {"hours": 24}, "server": {"port": 8000}}
>>> config.merge_settings_from_dict(new_data)
"""
self._setup(**merge_models(self, data))
@@ -478,86 +426,31 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""
self._setup()
def revert_settings(self, backup_id: str) -> None:
"""Revert application settings to a stored backup.
def set_config_value(self, path: str, value: Any) -> None:
"""Set a configuration value based on the provided path.
This method restores configuration values from a backup file identified
by `backup_id`. The backup is expected to exist alongside the main
configuration file, using the main config file's path but with the given
suffix. Any settings previously applied will be overwritten.
Supports string paths (with '/' separators) or sequence paths (list/tuple).
Trims leading and trailing '/' from string paths.
Args:
backup_id (str): The suffix used to locate the backup configuration
file. Example: ``".bak"`` or ``".backup"``.
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
value (Any]): The value to set.
"""
access_nested_value(self, path, True, value)
def get_config_value(self, path: str) -> Any:
"""Get a configuration value based on the provided path.
Supports string paths (with '/' separators) or sequence paths (list/tuple).
Trims leading and trailing '/' from string paths.
Args:
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
Returns:
None: The method does not return a value.
Raises:
ValueError: If the backup file cannot be found at the constructed path.
json.JSONDecodeError: If the backup file exists but contains invalid JSON.
TypeError: If the unpacked backup data fails to match the signature
required by ``self._setup()``.
OSError: If reading the backup file fails due to I/O issues.
Any: The retrieved value.
"""
backup_file_path = self.general.config_file_path.with_suffix(f".{backup_id}")
if not backup_file_path.exists():
error_msg = f"Configuration backup `{backup_id}` not found."
logger.error(error_msg)
raise ValueError(error_msg)
with backup_file_path.open("r", encoding="utf-8") as f:
backup_data: dict[str, Any] = json.load(f)
backup_settings = migrate_config_data(backup_data)
self._setup(**backup_settings.model_dump(exclude_none=True, exclude_unset=True))
def list_backups(self) -> dict[str, dict[str, Any]]:
"""List available configuration backup files and extract metadata.
Backup files are identified by sharing the same stem as the main config
file but having a different suffix. Each backup file is assumed to contain
a JSON object.
The returned dictionary uses `backup_id` (suffix) as keys. The value for
each key is a dictionary including:
- ``storage_time``: The file modification timestamp in ISO-8601 format.
- ``version``: Version information found in the backup file (defaults to ``"unknown"``).
Returns:
dict[str, dict[str, Any]]: Mapping of backup identifiers to metadata.
Raises:
OSError: If directory scanning or file reading fails.
json.JSONDecodeError: If a backup file cannot be parsed as JSON.
"""
result: dict[str, dict[str, Any]] = {}
base_path: Path = self.general.config_file_path
parent = base_path.parent
stem = base_path.stem
# Iterate files next to config file
for file in parent.iterdir():
if file.is_file() and file.stem == stem and file != base_path:
backup_id = file.suffix[1:]
# Read version from file
with file.open("r", encoding="utf-8") as f:
data: dict[str, Any] = json.load(f)
# Extract version safely
version = data.get("general", {}).get("version", "unknown")
# Read file modification time (OS-independent)
ts = file.stat().st_mtime
storage_time = to_datetime(ts, as_string=True)
result[backup_id] = {
"date_time": storage_time,
"version": version,
}
return result
return access_nested_value(self, path, False)
def _create_initial_config_file(self) -> None:
if self.general.config_file_path and not self.general.config_file_path.exists():
@@ -604,15 +497,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.
Searches:
1. environment variable directory
2. user configuration directory
3. current working directory
"""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)
@@ -641,7 +529,7 @@ 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)
json_str = super().model_dump_json()
f_out.write(json_str)
def update(self) -> None:

View File

@@ -1,255 +0,0 @@
"""Migrate config file to actual version."""
import json
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Set, Tuple, Union
from loguru import logger
from akkudoktoreos.core.version import __version__
if TYPE_CHECKING:
# There are circular dependencies - only import here for type checking
from akkudoktoreos.config.config import SettingsEOSDefaults
# -----------------------------
# Global migration map constant
# -----------------------------
# key: old JSON path, value: either
# - str (new model path)
# - tuple[str, Callable[[Any], Any]] (new path + transform)
# - None (drop)
MIGRATION_MAP: Dict[str, Union[str, Tuple[str, Callable[[Any], Any]], None]] = {
# 0.2.0 -> 0.2.0+dev
"elecprice/provider_settings/ElecPriceImport/import_file_path": "elecprice/elecpriceimport/import_file_path",
"elecprice/provider_settings/ElecPriceImport/import_json": "elecprice/elecpriceimport/import_json",
# 0.1.0 -> 0.2.0+dev
"devices/batteries/0/initial_soc_percentage": None,
"devices/electric_vehicles/0/initial_soc_percentage": None,
"elecprice/provider_settings/import_file_path": "elecprice/elecpriceimport/import_file_path",
"elecprice/provider_settings/import_json": "elecprice/elecpriceimport/import_json",
"load/provider_settings/import_file_path": "load/provider_settings/LoadImport/import_file_path",
"load/provider_settings/import_json": "load/provider_settings/LoadImport/import_json",
"load/provider_settings/loadakkudoktor_year_energy": "load/provider_settings/LoadAkkudoktor/loadakkudoktor_year_energy_kwh",
"load/provider_settings/load_vrm_idsite": "load/provider_settings/LoadVrm/load_vrm_idsite",
"load/provider_settings/load_vrm_token": "load/provider_settings/LoadVrm/load_vrm_token",
"logging/level": "logging/console_level",
"logging/root_level": None,
"measurement/load0_name": "measurement/load_emr_keys/0",
"measurement/load1_name": "measurement/load_emr_keys/1",
"measurement/load2_name": "measurement/load_emr_keys/2",
"measurement/load3_name": "measurement/load_emr_keys/3",
"measurement/load4_name": "measurement/load_emr_keys/4",
"optimization/ev_available_charge_rates_percent": (
"devices/electric_vehicles/0/charge_rates",
lambda v: [x / 100 for x in v],
),
"optimization/hours": "optimization/horizon_hours",
"optimization/penalty": ("optimization/genetic/penalties/ev_soc_miss", lambda v: float(v)),
"pvforecast/provider_settings/import_file_path": "pvforecast/provider_settings/PVForecastImport/import_file_path",
"pvforecast/provider_settings/import_json": "pvforecast/provider_settings/PVForecastImport/import_json",
"pvforecast/provider_settings/load_vrm_idsite": "pvforecast/provider_settings/PVForecastVrm/load_vrm_idsite",
"pvforecast/provider_settings/load_vrm_token": "pvforecast/provider_settings/PVForecastVrm/load_vrm_token",
"weather/provider_settings/import_file_path": "weather/provider_settings/WeatherImport/import_file_path",
"weather/provider_settings/import_json": "weather/provider_settings/WeatherImport/import_json",
}
# -----------------------------
# Global migration stats
# -----------------------------
migrated_source_paths: Set[str] = set()
mapped_count: int = 0
auto_count: int = 0
skipped_paths: List[str] = []
def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
"""Migrate configuration data to the current version settings.
Returns:
SettingsEOSDefaults: The migrated settings.
"""
global migrated_source_paths, mapped_count, auto_count, skipped_paths
# Reset globals at the start of each migration
migrated_source_paths = set()
mapped_count = 0
auto_count = 0
skipped_paths = []
from akkudoktoreos.config.config import SettingsEOSDefaults
new_config = SettingsEOSDefaults()
# 1) Apply explicit migration map
for old_path, mapping in MIGRATION_MAP.items():
new_path = None
transform = None
if mapping is None:
migrated_source_paths.add(old_path.strip("/"))
logger.debug(f"🗑️ Migration map: dropping '{old_path}'")
continue
if isinstance(mapping, tuple):
new_path, transform = mapping
else:
new_path = mapping
old_value = _get_json_nested_value(config_data, old_path)
if old_value is None:
migrated_source_paths.add(old_path.strip("/"))
mapped_count += 1
logger.debug(f"✅ Migrated mapped '{old_path}''None'")
continue
try:
if transform:
old_value = transform(old_value)
new_config.set_nested_value(new_path, old_value)
migrated_source_paths.add(old_path.strip("/"))
mapped_count += 1
logger.debug(f"✅ Migrated mapped '{old_path}''{new_path}' = {old_value!r}")
except Exception as e:
logger.opt(exception=True).warning(
f"Failed mapped migration '{old_path}' -> '{new_path}': {e}"
)
# 2) Automatic migration for remaining fields
auto_count += _migrate_matching_fields(
config_data, new_config, migrated_source_paths, skipped_paths
)
# 3) Ensure version
try:
new_config.set_nested_value("general/version", __version__)
except Exception as e:
logger.warning(f"Could not set version on new configuration model: {e}")
# 4) Log final migration summary
logger.info(
f"Migration summary: "
f"mapped fields: {mapped_count}, automatically migrated: {auto_count}, skipped: {len(skipped_paths)}"
)
if skipped_paths:
logger.debug(f"Skipped paths: {', '.join(skipped_paths)}")
logger.success(f"Configuration successfully migrated to version {__version__}.")
return new_config
def migrate_config_file(config_file: Path, backup_file: Path) -> bool:
"""Migrate configuration file to the current version.
Returns:
bool: True if up-to-date or successfully migrated, False on failure.
"""
global migrated_source_paths, mapped_count, auto_count, skipped_paths
# Reset globals at the start of each migration
migrated_source_paths = set()
mapped_count = 0
auto_count = 0
skipped_paths = []
try:
with config_file.open("r", encoding="utf-8") as f:
config_data: Dict[str, Any] = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.error(f"Failed to read configuration file '{config_file}': {e}")
return False
match config_data:
case {"general": {"version": v}} if v == __version__:
logger.debug(f"Configuration file '{config_file}' is up to date (v{v}).")
return True
case _:
logger.info(
f"Configuration file '{config_file}' is missing current version info. "
f"Starting migration to v{__version__}..."
)
try:
# Backup existing file - we already know it is existing
try:
config_file.replace(backup_file)
logger.info(f"Backed up old configuration to '{backup_file}'.")
except Exception as e_replace:
try:
shutil.copy(config_file, backup_file)
logger.info(
f"Could not replace; copied old configuration to '{backup_file}' instead."
)
except Exception as e_copy:
logger.warning(
f"Failed to backup existing config (replace: {e_replace}; copy: {e_copy}). Continuing without backup."
)
# Migrate config data
new_config = migrate_config_data(config_data)
# Write migrated configuration
try:
with config_file.open("w", encoding="utf-8", newline=None) as f_out:
json_str = new_config.model_dump_json(indent=4)
f_out.write(json_str)
except Exception as e_write:
logger.error(f"Failed to write migrated configuration to '{config_file}': {e_write}")
return False
return True
except Exception as e:
logger.exception(f"Unexpected error during migration: {e}")
return False
def _get_json_nested_value(data: dict, path: str) -> Any:
"""Retrieve a nested value from a JSON-like dict using '/'-separated path."""
current: Any = data
for part in path.strip("/").split("/"):
if isinstance(current, list):
try:
part_idx = int(part)
current = current[part_idx]
except (ValueError, IndexError):
return None
elif isinstance(current, dict):
if part not in current:
return None
current = current[part]
else:
return None
return current
def _migrate_matching_fields(
source: Dict[str, Any],
target_model: Any,
migrated_source_paths: Set[str],
skipped_paths: List[str],
prefix: str = "",
) -> int:
"""Recursively copy matching keys from source dict into target_model using set_nested_value.
Returns:
int: number of fields successfully auto-migrated
"""
count: int = 0
for key, value in source.items():
full_path = f"{prefix}/{key}".strip("/")
if full_path in migrated_source_paths:
continue
if isinstance(value, dict):
count += _migrate_matching_fields(
value, target_model, migrated_source_paths, skipped_paths, full_path
)
else:
try:
target_model.set_nested_value(full_path, value)
count += 1
except Exception:
skipped_paths.append(full_path)
continue
return count

View File

@@ -27,18 +27,16 @@ from typing import (
)
import cachebox
from loguru import logger
from pendulum import DateTime, Duration
from pydantic import Field
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
compare_datetimes,
to_datetime,
to_duration,
)
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
logger = get_logger(__name__)
# ---------------------------------
# In-Memory Caching Functionality
@@ -48,28 +46,25 @@ from akkudoktoreos.utils.datetimeutil import (
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
def cache_energy_management_store_callback(event: int, key: Any, value: Any) -> None:
"""Calback function for CacheEnergyManagementStore."""
CacheEnergyManagementStore.last_event = event
CacheEnergyManagementStore.last_key = key
CacheEnergyManagementStore.last_value = value
def cache_until_update_store_callback(event: int, key: Any, value: Any) -> None:
"""Calback function for CacheUntilUpdateStore."""
CacheUntilUpdateStore.last_event = event
CacheUntilUpdateStore.last_key = key
CacheUntilUpdateStore.last_value = value
if event == cachebox.EVENT_MISS:
CacheEnergyManagementStore.miss_count += 1
CacheUntilUpdateStore.miss_count += 1
elif event == cachebox.EVENT_HIT:
CacheEnergyManagementStore.hit_count += 1
CacheUntilUpdateStore.hit_count += 1
else:
# unreachable code
raise NotImplementedError
class CacheEnergyManagementStore(SingletonMixin):
class CacheUntilUpdateStore(SingletonMixin):
"""Singleton-based in-memory LRU (Least Recently Used) cache.
This cache is shared across the application to store results of decorated
methods or functions during energy management runs.
Energy management tasks shall clear the cache at the start of the energy management
task.
methods or functions until the next EMS (Energy Management System) update.
The cache uses an LRU eviction strategy, storing up to 100 items, with the oldest
items being evicted once the cache reaches its capacity.
@@ -83,17 +78,14 @@ class CacheEnergyManagementStore(SingletonMixin):
miss_count: ClassVar[int] = 0
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initializes the `CacheEnergyManagementStore` instance with default parameters.
"""Initializes the `CacheUntilUpdateStore` instance with default parameters.
The cache uses an LRU eviction strategy with a maximum size of 100 items.
This cache is a singleton, meaning only one instance will exist throughout
the application lifecycle.
Example:
.. code-block:: python
cache = CacheEnergyManagementStore()
>>> cache = CacheUntilUpdateStore()
"""
if hasattr(self, "_initialized"):
return
@@ -115,10 +107,7 @@ class CacheEnergyManagementStore(SingletonMixin):
AttributeError: If the cache object does not have the requested method.
Example:
.. code-block:: python
result = cache.get("key")
>>> result = cache.get("key")
"""
# This will return a method of the target cache, or raise an AttributeError
target_attr = getattr(self.cache, name)
@@ -140,12 +129,9 @@ class CacheEnergyManagementStore(SingletonMixin):
KeyError: If the key does not exist in the cache.
Example:
.. code-block:: python
value = cache["user_data"]
>>> value = cache["user_data"]
"""
return CacheEnergyManagementStore.cache[key]
return CacheUntilUpdateStore.cache[key]
def __setitem__(self, key: Any, value: Any) -> None:
"""Stores an item in the cache.
@@ -155,20 +141,17 @@ class CacheEnergyManagementStore(SingletonMixin):
value (Any): The value to store.
Example:
.. code-block:: python
cache["user_data"] = {"name": "Alice", "age": 30}
>>> cache["user_data"] = {"name": "Alice", "age": 30}
"""
CacheEnergyManagementStore.cache[key] = value
CacheUntilUpdateStore.cache[key] = value
def __len__(self) -> int:
"""Returns the number of items in the cache."""
return len(CacheEnergyManagementStore.cache)
return len(CacheUntilUpdateStore.cache)
def __repr__(self) -> str:
"""Provides a string representation of the CacheEnergyManagementStore object."""
return repr(CacheEnergyManagementStore.cache)
"""Provides a string representation of the CacheUntilUpdateStore object."""
return repr(CacheUntilUpdateStore.cache)
def clear(self) -> None:
"""Clears the cache, removing all stored items.
@@ -178,51 +161,77 @@ class CacheEnergyManagementStore(SingletonMixin):
management system run).
Example:
.. code-block:: python
cache.clear()
>>> cache.clear()
"""
if hasattr(self.cache, "clear") and callable(getattr(self.cache, "clear")):
CacheEnergyManagementStore.cache.clear()
CacheEnergyManagementStore.last_event = None
CacheEnergyManagementStore.last_key = None
CacheEnergyManagementStore.last_value = None
CacheEnergyManagementStore.miss_count = 0
CacheEnergyManagementStore.hit_count = 0
CacheUntilUpdateStore.cache.clear()
CacheUntilUpdateStore.last_event = None
CacheUntilUpdateStore.last_key = None
CacheUntilUpdateStore.last_value = None
CacheUntilUpdateStore.miss_count = 0
CacheUntilUpdateStore.hit_count = 0
else:
raise AttributeError(f"'{self.cache.__class__.__name__}' object has no method 'clear'")
def cache_energy_management(callable: TCallable) -> TCallable:
"""Decorator for in memory caching the result of a callable.
def cachemethod_until_update(method: TCallable) -> TCallable:
"""Decorator for in memory caching the result of an instance method.
This decorator caches the method or function's result in `CacheEnergyManagementStore`,
ensuring that subsequent calls with the same arguments return the cached result until the
next energy management start.
This decorator caches the method's result in `CacheUntilUpdateStore`, ensuring
that subsequent calls with the same arguments return the cached result until the
next EMS update cycle.
Args:
callable (Callable): The function or method to be decorated.
method (Callable): The instance method to be decorated.
Returns:
Callable: The wrapped method with caching functionality.
Example:
>>> class MyClass:
>>> @cachemethod_until_update
>>> def expensive_method(self, param: str) -> str:
>>> # Perform expensive computation
>>> return f"Computed {param}"
"""
@cachebox.cachedmethod(
cache=CacheUntilUpdateStore().cache, callback=cache_until_update_store_callback
)
@functools.wraps(method)
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
result = method(self, *args, **kwargs)
return result
return wrapper
def cache_until_update(func: TCallable) -> TCallable:
"""Decorator for in memory caching the result of a standalone function.
This decorator caches the function's result in `CacheUntilUpdateStore`, ensuring
that subsequent calls with the same arguments return the cached result until the
next EMS update cycle.
Args:
func (Callable): The function to be decorated.
Returns:
Callable: The wrapped function with caching functionality.
Example:
.. code-block:: python
@cache_energy_management
def expensive_function(param: str) -> str:
# Perform expensive computation
return f"Computed {param}"
>>> @cache_until_next_update
>>> def expensive_function(param: str) -> str:
>>> # Perform expensive computation
>>> return f"Computed {param}"
"""
@cachebox.cached(
cache=CacheEnergyManagementStore().cache, callback=cache_energy_management_store_callback
cache=CacheUntilUpdateStore().cache, callback=cache_until_update_store_callback
)
@functools.wraps(callable)
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
result = callable(*args, **kwargs)
result = func(*args, **kwargs)
return result
return wrapper
@@ -237,14 +246,10 @@ RetType = TypeVar("RetType")
class CacheFileRecord(PydanticBaseModel):
cache_file: Any = Field(
..., json_schema_extra={"description": "File descriptor of the cache file."}
)
until_datetime: DateTime = Field(
..., json_schema_extra={"description": "Datetime until the cache file is valid."}
)
cache_file: Any = Field(..., description="File descriptor of the cache file.")
until_datetime: DateTime = Field(..., description="Datetime until the cache file is valid.")
ttl_duration: Optional[Duration] = Field(
default=None, json_schema_extra={"description": "Duration the cache file is valid."}
default=None, description="Duration the cache file is valid."
)
@@ -263,15 +268,12 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
with their associated keys and dates.
Example:
.. code-block:: python
cache_store = CacheFileStore()
cache_store.create('example_file')
cache_file = cache_store.get('example_file')
cache_file.write('Some data')
cache_file.seek(0)
print(cache_file.read()) # Output: 'Some data'
>>> cache_store = CacheFileStore()
>>> cache_store.create('example_file')
>>> cache_file = cache_store.get('example_file')
>>> cache_file.write('Some data')
>>> cache_file.seek(0)
>>> print(cache_file.read()) # Output: 'Some data'
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -436,7 +438,7 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
)
logger.debug(
f"Search: ttl:{ttl_duration}, until:{until_datetime}, at:{at_datetime}, before:{before_datetime} -> hit: {generated_key == cache_file_key}, item: {cache_item.cache_file.seek(0), cache_item.cache_file.read()[:10]}..."
f"Search: ttl:{ttl_duration}, until:{until_datetime}, at:{at_datetime}, before:{before_datetime} -> hit: {generated_key == cache_file_key}, item: {cache_item.cache_file.seek(0), cache_item.cache_file.read()}"
)
if generated_key == cache_file_key:
@@ -480,13 +482,10 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
file_obj: A file-like object representing the cache file.
Example:
.. code-block:: python
cache_file = cache_store.create('example_file', suffix='.txt')
cache_file.write('Some cached data')
cache_file.seek(0)
print(cache_file.read()) # Output: 'Some cached data'
>>> cache_file = cache_store.create('example_file', suffix='.txt')
>>> cache_file.write('Some cached data')
>>> cache_file.seek(0)
>>> print(cache_file.read()) # Output: 'Some cached data'
"""
cache_file_key, until_datetime_dt, ttl_duration = self._generate_cache_file_key(
key, until_datetime=until_datetime, until_date=until_date, with_ttl=with_ttl
@@ -535,10 +534,7 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
ValueError: If the key is already in store.
Example:
.. code-block:: python
cache_store.set('example_file', io.BytesIO(b'Some binary data'))
>>> cache_store.set('example_file', io.BytesIO(b'Some binary data'))
"""
cache_file_key, until_datetime_dt, ttl_duration = self._generate_cache_file_key(
key, until_datetime=until_datetime, until_date=until_date, with_ttl=with_ttl
@@ -594,13 +590,10 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
file_obj: The file-like cache object, or None if no file is found.
Example:
.. code-block:: python
cache_file = cache_store.get('example_file')
if cache_file:
cache_file.seek(0)
print(cache_file.read()) # Output: Cached data (if exists)
>>> cache_file = cache_store.get('example_file')
>>> if cache_file:
>>> cache_file.seek(0)
>>> print(cache_file.read()) # Output: Cached data (if exists)
"""
if until_datetime or until_date:
until_datetime, _ttl_duration = self._until_datetime_by_options(
@@ -879,15 +872,13 @@ def cache_in_file(
A decorated function that caches its result in a temporary file.
Example:
.. code-block:: python
from datetime import date
@cache_in_file(suffix='.txt')
def expensive_computation(until_date=None):
# Perform some expensive computation
return 'Some large result'
result = expensive_computation(until_date=date.today())
>>> from datetime import date
>>> @cache_in_file(suffix='.txt')
>>> def expensive_computation(until_date=None):
>>> # Perform some expensive computation
>>> return 'Some large result'
>>>
>>> result = expensive_computation(until_date=date.today())
Notes:
- The cache key is based on the function arguments after excluding those in `ignore_params`.
@@ -965,7 +956,7 @@ def cache_in_file(
logger.debug("Used cache file for function: " + func.__name__)
cache_file.seek(0)
if "b" in mode:
result = pickle.load(cache_file) # noqa: S301
result = pickle.load(cache_file)
else:
result = cache_file.read()
except Exception as e:

View File

@@ -15,13 +15,11 @@ class CacheCommonSettings(SettingsBaseModel):
"""Cache Configuration."""
subpath: Optional[Path] = Field(
default="cache",
json_schema_extra={"description": "Sub-path for the EOS cache data directory."},
default="cache", description="Sub-path for the EOS cache data directory."
)
cleanup_interval: float = Field(
default=5 * 60,
json_schema_extra={"description": "Intervall in seconds for EOS file cache cleanup."},
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

View File

@@ -13,14 +13,17 @@ 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.decorators import classproperty
from akkudoktoreos.utils.datetimeutil import DateTime
from akkudoktoreos.core.logging import get_logger
logger = get_logger(__name__)
config_eos: Any = None
measurement_eos: Any = None
prediction_eos: Any = None
devices_eos: Any = None
ems_eos: Any = None
@@ -39,17 +42,16 @@ class ConfigMixin:
config (ConfigEOS): Property to access the global EOS configuration.
Example:
.. code-block:: python
```python
class MyEOSClass(ConfigMixin):
def my_method(self):
if self.config.myconfigval:
```
"""
@classproperty
def config(cls) -> Any:
"""Convenience class method/ attribute to retrieve the EOS configuration data.
@property
def config(self) -> Any:
"""Convenience method/ attribute to retrieve the EOS configuration data.
Returns:
ConfigEOS: The configuration.
@@ -79,18 +81,17 @@ class MeasurementMixin:
measurement (Measurement): Property to access the global EOS measurement data.
Example:
.. code-block:: python
```python
class MyOptimizationClass(MeasurementMixin):
def analyze_mymeasurement(self):
measurement_data = self.measurement.mymeasurement
# Perform analysis
```
"""
@classproperty
def measurement(cls) -> Any:
"""Convenience class method/ attribute to retrieve the EOS measurement data.
@property
def measurement(self) -> Any:
"""Convenience method/ attribute to retrieve the EOS measurement data.
Returns:
Measurement: The measurement.
@@ -120,18 +121,17 @@ class PredictionMixin:
prediction (Prediction): Property to access the global EOS prediction data.
Example:
.. code-block:: python
```python
class MyOptimizationClass(PredictionMixin):
def analyze_myprediction(self):
prediction_data = self.prediction.mypredictionresult
# Perform analysis
```
"""
@classproperty
def prediction(cls) -> Any:
"""Convenience class method/ attribute to retrieve the EOS prediction data.
@property
def prediction(self) -> Any:
"""Convenience method/ attribute to retrieve the EOS prediction data.
Returns:
Prediction: The prediction.
@@ -146,6 +146,46 @@ class PredictionMixin:
return prediction_eos
class DevicesMixin:
"""Mixin class for managing EOS devices simulation data.
This class serves as a foundational component for EOS-related classes requiring access
to global devices simulation data. It provides a `devices` property that dynamically retrieves
the devices instance, ensuring up-to-date access to devices simulation results.
Usage:
Subclass this base class to gain access to the `devices` attribute, which retrieves the
global devices instance lazily to avoid import-time circular dependencies.
Attributes:
devices (Devices): Property to access the global EOS devices simulation data.
Example:
```python
class MyOptimizationClass(DevicesMixin):
def analyze_mydevicesimulation(self):
device_simulation_data = self.devices.mydevicesresult
# Perform analysis
```
"""
@property
def devices(self) -> Any:
"""Convenience method/ attribute to retrieve the EOS devices simulation data.
Returns:
Devices: The devices simulation.
"""
# avoid circular dependency at import time
global devices_eos
if devices_eos is None:
from akkudoktoreos.devices.devices import get_devices
devices_eos = get_devices()
return devices_eos
class EnergyManagementSystemMixin:
"""Mixin class for managing EOS energy management system.
@@ -162,18 +202,17 @@ class EnergyManagementSystemMixin:
ems (EnergyManagementSystem): Property to access the global EOS energy management system.
Example:
.. code-block:: python
```python
class MyOptimizationClass(EnergyManagementSystemMixin):
def analyze_myprediction(self):
ems_data = self.ems.the_ems_method()
# Perform analysis
```
"""
@classproperty
def ems(cls) -> Any:
"""Convenience class method/ attribute to retrieve the EOS energy management system.
@property
def ems(self) -> Any:
"""Convenience method/ attribute to retrieve the EOS energy management system.
Returns:
EnergyManagementSystem: The energy management system.
@@ -195,21 +234,16 @@ class StartMixin(EnergyManagementSystemMixin):
- `start_datetime`: The starting datetime of the current or latest energy management.
"""
@classproperty
def ems_start_datetime(cls) -> Optional[DateTime]:
"""Convenience class method/ attribute to retrieve the start datetime of the current or latest energy management.
# Computed field for start_datetime
@computed_field # type: ignore[prop-decorator]
@property
def start_datetime(self) -> Optional[DateTime]:
"""Returns the start datetime of the current or latest energy management.
Returns:
DateTime: The starting datetime of the current or latest energy management, or None.
"""
# avoid circular dependency at import time
global ems_eos
if ems_eos is None:
from akkudoktoreos.core.ems import get_ems
ems_eos = get_ems()
return ems_eos.start_datetime
return self.ems.start_datetime
class SingletonMixin:
@@ -228,8 +262,6 @@ class SingletonMixin:
- Avoid using `__init__` to reinitialize the singleton instance after it has been created.
Example:
.. code-block:: python
class MySingletonModel(SingletonMixin, PydanticBaseModel):
name: str
@@ -246,7 +278,6 @@ class SingletonMixin:
assert instance1 is instance2 # True
print(instance1.name) # Output: "Instance 1"
"""
_lock: ClassVar[threading.Lock] = threading.Lock()

View File

@@ -14,23 +14,13 @@ from abc import abstractmethod
from collections.abc import MutableMapping, MutableSequence
from itertools import chain
from pathlib import Path
from typing import (
Any,
Dict,
Iterator,
List,
Optional,
Tuple,
Type,
Union,
overload,
)
from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union, overload
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 (
AwareDatetime,
ConfigDict,
@@ -38,22 +28,18 @@ from pydantic import (
ValidationError,
computed_field,
field_validator,
model_validator,
)
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin, StartMixin
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import (
PydanticBaseModel,
PydanticDateTimeData,
PydanticDateTimeDataFrame,
)
from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
compare_datetimes,
to_datetime,
to_duration,
)
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
logger = get_logger(__name__)
class DataBase(ConfigMixin, StartMixin, PydanticBaseModel):
@@ -71,11 +57,6 @@ class DataRecord(DataBase, MutableMapping):
Fields can be accessed and mutated both using dictionary-style access (`record['field_name']`)
and attribute-style access (`record.field_name`).
The data record also provides configured field like data. Configuration has to be done by the
derived class. Configuration is a list of key strings, which is usually taken from the EOS
configuration. The internal field for these data `configured_data` is mostly hidden from
dictionary-style and attribute-style access.
Attributes:
date_time (Optional[DateTime]): Aware datetime indicating when the data record applies.
@@ -84,48 +65,11 @@ class DataRecord(DataBase, MutableMapping):
- Supports non-standard data types like `datetime`.
"""
date_time: Optional[DateTime] = Field(
default=None, json_schema_extra={"description": "DateTime"}
)
configured_data: dict[str, Any] = Field(
default_factory=dict,
json_schema_extra={
"description": "Configured field like data",
"examples": [{"load0_mr": 40421}],
},
)
date_time: Optional[DateTime] = Field(default=None, description="DateTime")
# Pydantic v2 model configuration
model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)
@model_validator(mode="before")
@classmethod
def init_configured_field_like_data(cls, data: Any) -> Any:
"""Extracts configured data keys from the input and assigns them to `configured_data`.
This validator is called before the model is initialized. It filters out any keys from the input
dictionary that are listed in the configured data keys, and moves them into
the `configured_data` field of the model. This enables flexible, key-driven population of
dynamic data while keeping the model schema clean.
Args:
data (Any): The raw input data used to initialize the model.
Returns:
Any: The modified input data dictionary, with configured keys moved to `configured_data`.
"""
if not isinstance(data, dict):
return data
configured_keys: Union[list[str], set] = cls.configured_data_keys() or set()
extracted = {k: data.pop(k) for k in list(data.keys()) if k in configured_keys}
if extracted:
data.setdefault("configured_data", {}).update(extracted)
return data
@field_validator("date_time", mode="before")
@classmethod
def transform_to_datetime(cls, value: Any) -> Optional[DateTime]:
@@ -135,39 +79,18 @@ class DataRecord(DataBase, MutableMapping):
return None
return to_datetime(value)
@classmethod
def configured_data_keys(cls) -> Optional[list[str]]:
"""Return the keys for the configured field like data.
Can be overwritten by derived classes to define specific field like data. Usually provided
by configuration data.
"""
return None
@classmethod
def record_keys(cls) -> List[str]:
"""Returns the keys of all fields in the data record."""
key_list = []
key_list.extend(list(cls.model_fields.keys()))
key_list.extend(list(cls.__pydantic_decorators__.computed_fields.keys()))
# Add also keys that may be added by configuration
key_list.remove("configured_data")
configured_keys = cls.configured_data_keys()
if configured_keys is not None:
key_list.extend(configured_keys)
return key_list
@classmethod
def record_keys_writable(cls) -> List[str]:
"""Returns the keys of all fields in the data record that are writable."""
keys_writable = []
keys_writable.extend(list(cls.model_fields.keys()))
# Add also keys that may be added by configuration
keys_writable.remove("configured_data")
configured_keys = cls.configured_data_keys()
if configured_keys is not None:
keys_writable.extend(configured_keys)
return keys_writable
return list(cls.model_fields.keys())
def _validate_key_writable(self, key: str) -> None:
"""Verify that a specified key exists and is writable in the current record keys.
@@ -183,40 +106,6 @@ class DataRecord(DataBase, MutableMapping):
f"Key '{key}' is not in writable record keys: {self.record_keys_writable()}"
)
def __dir__(self) -> list[str]:
"""Extend the default `dir()` output to include configured field like data keys.
This enables editor auto-completion and interactive introspection, while hiding the internal
`configured_data` dictionary.
This ensures the configured field like data values appear like native fields,
in line with the base model's attribute behavior.
"""
base = super().__dir__()
keys = set(base)
# Expose configured data keys as attributes
configured_keys = self.configured_data_keys()
if configured_keys is not None:
keys.update(configured_keys)
# Explicitly hide the 'configured_data' internal dict
keys.discard("configured_data")
return sorted(keys)
def __eq__(self, other: Any) -> bool:
"""Ensure equality comparison includes the contents of the `configured_data` dict.
Contents of the `configured_data` dict are in addition to the base model fields.
"""
if not isinstance(other, self.__class__):
return NotImplemented
# Compare all fields except `configured_data`
if self.model_dump(exclude={"configured_data"}) != other.model_dump(
exclude={"configured_data"}
):
return False
# Compare `configured_data` explicitly
return self.configured_data == other.configured_data
def __getitem__(self, key: str) -> Any:
"""Retrieve the value of a field by key name.
@@ -229,10 +118,8 @@ class DataRecord(DataBase, MutableMapping):
Raises:
KeyError: If the specified key does not exist.
"""
try:
# Let getattr do the work
return self.__getattr__(key)
except:
if key in self.model_fields:
return getattr(self, key)
raise KeyError(f"'{key}' not found in the record fields.")
def __setitem__(self, key: str, value: Any) -> None:
@@ -245,10 +132,9 @@ class DataRecord(DataBase, MutableMapping):
Raises:
KeyError: If the specified key does not exist in the fields.
"""
try:
# Let setattr do the work
self.__setattr__(key, value)
except:
if key in self.model_fields:
setattr(self, key, value)
else:
raise KeyError(f"'{key}' is not a recognized field.")
def __delitem__(self, key: str) -> None:
@@ -260,9 +146,9 @@ class DataRecord(DataBase, MutableMapping):
Raises:
KeyError: If the specified key does not exist in the fields.
"""
try:
self.__delattr__(key)
except:
if key in self.model_fields:
setattr(self, key, None) # Optional: set to None instead of deleting
else:
raise KeyError(f"'{key}' is not a recognized field.")
def __iter__(self) -> Iterator[str]:
@@ -271,7 +157,7 @@ class DataRecord(DataBase, MutableMapping):
Returns:
Iterator[str]: An iterator over field names.
"""
return iter(self.record_keys_writable())
return iter(self.model_fields)
def __len__(self) -> int:
"""Return the number of fields in the data record.
@@ -279,7 +165,7 @@ class DataRecord(DataBase, MutableMapping):
Returns:
int: The number of defined fields.
"""
return len(self.record_keys_writable())
return len(self.model_fields)
def __repr__(self) -> str:
"""Provide a string representation of the data record.
@@ -287,7 +173,7 @@ class DataRecord(DataBase, MutableMapping):
Returns:
str: A string representation showing field names and their values.
"""
field_values = {field: getattr(self, field) for field in self.__class__.model_fields}
field_values = {field: getattr(self, field) for field in self.model_fields}
return f"{self.__class__.__name__}({field_values})"
def __getattr__(self, key: str) -> Any:
@@ -302,13 +188,8 @@ class DataRecord(DataBase, MutableMapping):
Raises:
AttributeError: If the field does not exist.
"""
if key in self.__class__.model_fields:
if key in self.model_fields:
return getattr(self, key)
if key in self.configured_data.keys():
return self.configured_data[key]
configured_keys = self.configured_data_keys()
if configured_keys is not None and key in configured_keys:
return None
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'")
def __setattr__(self, key: str, value: Any) -> None:
@@ -321,13 +202,9 @@ class DataRecord(DataBase, MutableMapping):
Raises:
AttributeError: If the attribute/field does not exist.
"""
if key in self.__class__.model_fields:
if key in self.model_fields:
super().__setattr__(key, value)
return
configured_keys = self.configured_data_keys()
if configured_keys is not None and key in configured_keys:
self.configured_data[key] = value
return
else:
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'")
def __delattr__(self, key: str) -> None:
@@ -339,20 +216,9 @@ class DataRecord(DataBase, MutableMapping):
Raises:
AttributeError: If the attribute/field does not exist.
"""
if key in self.__class__.model_fields:
data: Optional[dict]
if key == "configured_data":
data = dict()
if key in self.model_fields:
setattr(self, key, None) # Optional: set to None instead of deleting
else:
data = None
setattr(self, key, data)
return
if key in self.configured_data:
del self.configured_data[key]
return
configured_keys = self.configured_data_keys()
if configured_keys is not None and key in configured_keys:
return
super().__delattr__(key)
@classmethod
@@ -372,11 +238,10 @@ class DataRecord(DataBase, MutableMapping):
return None
# Get all descriptions from the fields
descriptions: dict[str, str] = {}
for field_name in cls.model_fields.keys():
desc = cls.field_description(field_name)
if desc:
descriptions[field_name] = desc
descriptions = {
field_name: field_info.description
for field_name, field_info in cls.model_fields.items()
}
# Use difflib to get close matches
matches = difflib.get_close_matches(
@@ -432,11 +297,10 @@ class DataSequence(DataBase, MutableSequence):
Derived classes have to provide their own records field with correct record type set.
Usage:
.. code-block:: python
# Example of creating, adding, and using DataSequence
class DerivedSequence(DataSquence):
records: List[DerivedDataRecord] = Field(default_factory=list, json_schema_extra={ "description": "List of data records" })
records: List[DerivedDataRecord] = Field(default_factory=list,
description="List of data records")
seq = DerivedSequence()
seq.insert(DerivedDataRecord(date_time=datetime.now(), temperature=72))
@@ -448,13 +312,10 @@ class DataSequence(DataBase, MutableSequence):
# Convert to Pandas Series
series = seq.key_to_series('temperature')
"""
# To be overloaded by derived classes.
records: List[DataRecord] = Field(
default_factory=list, json_schema_extra={"description": "List of data records"}
)
records: List[DataRecord] = Field(default_factory=list, description="List of data records")
# Derived fields (computed)
@computed_field # type: ignore[prop-decorator]
@@ -493,7 +354,10 @@ class DataSequence(DataBase, MutableSequence):
@property
def record_keys(self) -> List[str]:
"""Returns the keys of all fields in the data records."""
return self.record_class().record_keys()
key_list = []
key_list.extend(list(self.record_class().model_fields.keys()))
key_list.extend(list(self.record_class().__pydantic_decorators__.computed_fields.keys()))
return key_list
@computed_field # type: ignore[prop-decorator]
@property
@@ -507,7 +371,7 @@ class DataSequence(DataBase, MutableSequence):
Returns:
List[str]: A list of field keys that are writable in the data records.
"""
return self.record_class().record_keys_writable()
return list(self.record_class().model_fields.keys())
@classmethod
def record_class(cls) -> Type:
@@ -740,12 +604,9 @@ class DataSequence(DataBase, MutableSequence):
**kwargs: Key-value pairs as keyword arguments
Examples:
.. code-block:: python
update_value(date, 'temperature', 25.5)
update_value(date, {'temperature': 25.5, 'humidity': 80})
update_value(date, temperature=25.5, humidity=80)
>>> update_value(date, 'temperature', 25.5)
>>> update_value(date, {'temperature': 25.5, 'humidity': 80})
>>> update_value(date, temperature=25.5, humidity=80)
"""
# Process input arguments into a dictionary
values: Dict[str, Any] = {}
@@ -848,38 +709,6 @@ class DataSequence(DataBase, MutableSequence):
return filtered_data
def key_to_value(self, key: str, target_datetime: DateTime) -> Optional[float]:
"""Returns the value corresponding to the specified key that is nearest to the given datetime.
Args:
key (str): The key of the attribute in DataRecord to extract.
target_datetime (datetime): The datetime to search nearest to.
Returns:
Optional[float]: The value nearest to the given datetime, or None if no valid records are found.
Raises:
KeyError: If the specified key is not found in any of the DataRecords.
"""
self._validate_key(key)
# Filter out records with None or NaN values for the key
valid_records = [
record
for record in self.records
if record.date_time is not None
and getattr(record, key, None) not in (None, float("nan"))
]
if not valid_records:
return None
# Find the record with datetime nearest to target_datetime
target = to_datetime(target_datetime)
nearest_record = min(valid_records, key=lambda r: abs(r.date_time - target))
return getattr(nearest_record, key, None)
def key_to_lists(
self,
key: str,
@@ -982,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.
@@ -1041,11 +869,6 @@ class DataSequence(DataBase, MutableSequence):
KeyError: If the specified key is not found in any of the DataRecords.
"""
self._validate_key(key)
# General check on fill_method
if fill_method not in ("ffill", "bfill", "linear", "none", None):
raise ValueError(f"Unsupported fill method: {fill_method}")
# 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
@@ -1058,7 +881,7 @@ class DataSequence(DataBase, MutableSequence):
values_len = len(values)
if values_len < 1:
# No values, assume at least one value set to None
# No values, assume at at least one value set to None
if start_datetime is not None:
dates.append(start_datetime - interval)
else:
@@ -1080,11 +903,6 @@ class DataSequence(DataBase, MutableSequence):
# Truncate all values before latest value before start_datetime
dates = dates[start_index - 1 :]
values = values[start_index - 1 :]
# We have a start_datetime, align to start datetime
resample_origin = start_datetime
else:
# We do not have a start_datetime, align resample buckets to midnight of first day
resample_origin = "start_day"
if end_datetime is not None:
if compare_datetimes(dates[-1], end_datetime).lt:
@@ -1105,7 +923,7 @@ class DataSequence(DataBase, MutableSequence):
if fill_method is None:
fill_method = "linear"
# Resample the series to the specified interval
resampled = series.resample(interval, origin=resample_origin).first()
resampled = series.resample(interval, origin="start").first()
if fill_method == "linear":
resampled = resampled.interpolate(method="linear")
elif fill_method == "ffill":
@@ -1119,7 +937,7 @@ class DataSequence(DataBase, MutableSequence):
if fill_method is None:
fill_method = "ffill"
# Resample the series to the specified interval
resampled = series.resample(interval, origin=resample_origin).first()
resampled = series.resample(interval, origin="start").first()
if fill_method == "ffill":
resampled = resampled.ffill()
elif fill_method == "bfill":
@@ -1127,24 +945,12 @@ class DataSequence(DataBase, MutableSequence):
elif fill_method != "none":
raise ValueError(f"Unsupported fill method for non-numeric data: {fill_method}")
logger.debug(
"Resampled for '{}' with length {}: {}...{}",
key,
len(resampled),
resampled[:10],
resampled[-10:],
)
# Convert the resampled series to a NumPy array
if start_datetime is not None and len(resampled) > 0:
resampled = resampled.truncate(before=start_datetime)
if end_datetime is not None and len(resampled) > 0:
resampled = resampled.truncate(after=end_datetime.subtract(seconds=1))
array = resampled.values
logger.debug(
"Array for '{}' with length {}: {}...{}", key, len(array), array[:10], array[-10:]
)
return array
def to_dataframe(
@@ -1325,7 +1131,7 @@ class DataProvider(SingletonMixin, DataSequence):
"""
update_datetime: Optional[AwareDatetime] = Field(
None, json_schema_extra={"description": "Latest update datetime for generic data"}
None, description="Latest update datetime for generic data"
)
@abstractmethod
@@ -1384,18 +1190,15 @@ class DataImportMixin:
"""Mixin class for import of generic data.
This class is designed to handle generic data provided in the form of a key-value dictionary.
- **Keys**: Represent identifiers from the record keys of a specific data.
- **Values**: Are lists of data values starting at a specified start_datetime, where
- **Values**: Are lists of data values starting at a specified `start_datetime`, where
each value corresponds to a subsequent time interval (e.g., hourly).
Two special keys are handled. start_datetime may be used to defined the starting datetime of
the values. ìnterval may be used to define the fixed time interval between two values.
On import self.update_value(datetime, key, value) is called which has to be provided.
Also self.ems_start_datetime may be necessary as a default in case start_datetime is not
given.
Two special keys are handled. `start_datetime` may be used to defined the starting datetime of
the values. `ìnterval` may be used to define the fixed time interval between two values.
On import `self.update_value(datetime, key, value)` is called which has to be provided.
Also `self.start_datetime` may be necessary as a default in case `start_datetime`is not given.
"""
# Attributes required but defined elsehere.
@@ -1427,20 +1230,16 @@ class DataImportMixin:
Behavior:
- Skips invalid timestamps during DST spring forward transitions.
- Includes both instances of repeated timestamps during DST fall back transitions.
- Ensures the list contains exactly 'value_count' entries.
- Ensures the list contains exactly `value_count` entries.
Example:
.. code-block:: python
start_datetime = pendulum.datetime(2024, 11, 3, 0, 0, tz="America/New_York")
import_datetimes(start_datetime, 5)
>>> start_datetime = pendulum.datetime(2024, 11, 3, 0, 0, tz="America/New_York")
>>> import_datetimes(start_datetime, 5)
[(DateTime(2024, 11, 3, 0, 0, tzinfo=Timezone('America/New_York')), 0),
(DateTime(2024, 11, 3, 1, 0, tzinfo=Timezone('America/New_York')), 1),
(DateTime(2024, 11, 3, 1, 0, tzinfo=Timezone('America/New_York')), 1), # Repeated hour
(DateTime(2024, 11, 3, 2, 0, tzinfo=Timezone('America/New_York')), 2),
(DateTime(2024, 11, 3, 3, 0, tzinfo=Timezone('America/New_York')), 3)]
"""
timestamps_with_indices: List[Tuple[DateTime, int]] = []
@@ -1517,7 +1316,7 @@ class DataImportMixin:
raise ValueError(f"Invalid start_datetime in import data: {e}")
if start_datetime is None:
start_datetime = self.ems_start_datetime # type: ignore
start_datetime = self.start_datetime # type: ignore
if "interval" in import_data:
try:
@@ -1608,7 +1407,7 @@ class DataImportMixin:
raise ValueError(f"Invalid datetime index in DataFrame: {e}")
else:
if start_datetime is None:
start_datetime = self.ems_start_datetime # type: ignore
start_datetime = self.start_datetime # type: ignore
has_datetime_index = False
# Filter columns based on key_prefix and record_keys_writable
@@ -1665,7 +1464,7 @@ class DataImportMixin:
If start_datetime and or interval is given in the JSON dict it will be used. Otherwise
the given parameters are used. If None is given start_datetime defaults to
'self.ems_start_datetime' and interval defaults to 1 hour.
'self.start_datetime' and interval defaults to 1 hour.
Args:
json_str (str): The JSON string containing the generic data.
@@ -1678,18 +1477,17 @@ class DataImportMixin:
JSONDecodeError: If the file content is not valid JSON.
Example:
Given a JSON string with the following content and `key_prefix = "load"`, only the
"loadforecast_power_w" key will be processed even though both keys are in the record.
.. code-block:: json
Given a JSON string with the following content:
```json
{
"start_datetime": "2024-11-10 00:00:00",
"interval": "30 minutes",
"loadforecast_power_w": [20.5, 21.0, 22.1],
"other_xyz: [10.5, 11.0, 12.1]
"start_datetime": "2024-11-10 00:00:00"
"interval": "30 minutes"
"load_mean": [20.5, 21.0, 22.1],
"other_xyz: [10.5, 11.0, 12.1],
}
```
and `key_prefix = "load"`, only the "load_mean" key will be processed even though
both keys are in the record.
"""
# Try pandas dataframe with orient="split"
try:
@@ -1741,7 +1539,7 @@ class DataImportMixin:
If start_datetime and or interval is given in the JSON dict it will be used. Otherwise
the given parameters are used. If None is given start_datetime defaults to
'self.ems_start_datetime' and interval defaults to 1 hour.
'self.start_datetime' and interval defaults to 1 hour.
Args:
import_file_path (Path): The path to the JSON file containing the generic data.
@@ -1755,16 +1553,15 @@ class DataImportMixin:
JSONDecodeError: If the file content is not valid JSON.
Example:
Given a JSON file with the following content and `key_prefix = "load"`, only the
"loadforecast_power_w" key will be processed even though both keys are in the record.
.. code-block:: json
Given a JSON file with the following content:
```json
{
"loadforecast_power_w": [20.5, 21.0, 22.1],
"load_mean": [20.5, 21.0, 22.1],
"other_xyz: [10.5, 11.0, 12.1],
}
```
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:
import_str = import_file.read()
@@ -1777,7 +1574,6 @@ class DataImportProvider(DataImportMixin, DataProvider):
"""Abstract base class for data providers that import generic data.
This class is designed to handle generic data provided in the form of a key-value dictionary.
- **Keys**: Represent identifiers from the record keys of a specific data.
- **Values**: Are lists of data values starting at a specified `start_datetime`, where
each value corresponds to a subsequent time interval (e.g., hourly).
@@ -1802,7 +1598,7 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
# To be overloaded by derived classes.
providers: List[DataProvider] = Field(
default_factory=list, json_schema_extra={"description": "List of data providers"}
default_factory=list, description="List of data providers"
)
@field_validator("providers", mode="after")
@@ -1954,12 +1750,7 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
force_update (bool, optional): If True, forces the providers to update the data even if still cached.
"""
for provider in self.providers:
try:
provider.update_data(force_enable=force_enable, force_update=force_update)
except Exception as ex:
error = f"Provider {provider.provider_id()} fails on update - enabled={provider.enabled()}, force_enable={force_enable}, force_update={force_update}: {ex}"
logger.error(error)
raise RuntimeError(error)
def key_to_series(
self,
@@ -2064,7 +1855,7 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
) -> 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.
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.
@@ -2113,15 +1904,8 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping):
end_datetime.add(seconds=1)
# Create a DatetimeIndex based on start, end, and interval
if start_datetime is None or end_datetime is None:
raise ValueError(
f"Can not determine datetime range. Got '{start_datetime}'..'{end_datetime}'."
)
reference_index = pd.date_range(
start=start_datetime,
end=end_datetime,
freq=interval,
inclusive="left",
start=start_datetime, end=end_datetime, freq=interval, inclusive="left"
)
data = {}

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.
@@ -12,8 +16,6 @@ class classproperty:
the class rather than any instance of the class.
Example:
.. code-block:: python
class MyClass:
_value = 42
@@ -32,7 +34,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:
@@ -41,6 +43,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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +1,124 @@
import traceback
from asyncio import Lock, get_running_loop
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from typing import ClassVar, Optional
from typing import Any, ClassVar, Optional
from loguru import logger
from pydantic import computed_field
import numpy as np
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 CacheEnergyManagementStore
from akkudoktoreos.core.cache import CacheUntilUpdateStore
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin
from akkudoktoreos.core.emplan import EnergyManagementPlan
from akkudoktoreos.core.emsettings import EnergyManagementMode
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime
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.utils import NumpyEncoder
# The executor to execute the CPU heavy energy management run
executor = ThreadPoolExecutor(max_workers=1)
logger = get_logger(__name__)
class EnergyManagementParameters(ParametersBaseModel):
pv_prognose_wh: list[float] = Field(
description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
)
strompreis_euro_pro_wh: list[float] = Field(
description="An array of floats representing the electricity price in euros per watt-hour for different time intervals."
)
einspeiseverguetung_euro_pro_wh: list[float] | float = Field(
description="A float or array of floats representing the feed-in compensation in euros per watt-hour."
)
preis_euro_pro_wh_akku: float = Field(
description="A float representing the cost of battery energy per watt-hour."
)
gesamtlast: list[float] = Field(
description="An array of floats representing the total load (consumption) in watts for different time intervals."
)
@model_validator(mode="after")
def validate_list_length(self) -> Self:
pv_prognose_length = len(self.pv_prognose_wh)
if (
pv_prognose_length != len(self.strompreis_euro_pro_wh)
or pv_prognose_length != len(self.gesamtlast)
or (
isinstance(self.einspeiseverguetung_euro_pro_wh, list)
and pv_prognose_length != len(self.einspeiseverguetung_euro_pro_wh)
)
):
raise ValueError("Input lists have different lengths")
return self
class SimulationResult(ParametersBaseModel):
"""This object contains the results of the simulation and provides insights into various parameters over the entire forecast period."""
Last_Wh_pro_Stunde: list[Optional[float]] = Field(description="TBD")
EAuto_SoC_pro_Stunde: list[Optional[float]] = Field(
description="The state of charge of the EV for each hour."
)
Einnahmen_Euro_pro_Stunde: list[Optional[float]] = Field(
description="The revenue from grid feed-in or other sources in euros per hour."
)
Gesamt_Verluste: float = Field(
description="The total losses in watt-hours over the entire period."
)
Gesamtbilanz_Euro: float = Field(
description="The total balance of revenues minus costs in euros."
)
Gesamteinnahmen_Euro: float = Field(description="The total revenues in euros.")
Gesamtkosten_Euro: float = Field(description="The total costs in euros.")
Home_appliance_wh_per_hour: list[Optional[float]] = Field(
description="The energy consumption of a household appliance in watt-hours per hour."
)
Kosten_Euro_pro_Stunde: list[Optional[float]] = Field(
description="The costs in euros per hour."
)
Netzbezug_Wh_pro_Stunde: list[Optional[float]] = Field(
description="The grid energy drawn in watt-hours per hour."
)
Netzeinspeisung_Wh_pro_Stunde: list[Optional[float]] = Field(
description="The energy fed into the grid in watt-hours per hour."
)
Verluste_Pro_Stunde: list[Optional[float]] = Field(
description="The losses in watt-hours per hour."
)
akku_soc_pro_stunde: list[Optional[float]] = Field(
description="The state of charge of the battery (not the EV) in percentage per hour."
)
Electricity_price: list[Optional[float]] = Field(
description="Used Electricity Price, including predictions"
)
@field_validator(
"Last_Wh_pro_Stunde",
"Netzeinspeisung_Wh_pro_Stunde",
"akku_soc_pro_stunde",
"Netzbezug_Wh_pro_Stunde",
"Kosten_Euro_pro_Stunde",
"Einnahmen_Euro_pro_Stunde",
"EAuto_SoC_pro_Stunde",
"Verluste_Pro_Stunde",
"Home_appliance_wh_per_hour",
"Electricity_price",
mode="before",
)
def convert_numpy(cls, field: Any) -> Any:
return NumpyEncoder.convert_numpy(field)[0]
class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBaseModel):
"""Energy management."""
# Disable validation on assignment to speed up simulation runs.
model_config = ConfigDict(
validate_assignment=False,
)
# Start datetime.
_start_datetime: ClassVar[Optional[DateTime]] = None
# last run datetime. Used by energy management task
_last_run_datetime: ClassVar[Optional[DateTime]] = None
# energy management plan of latest energy management run with optimization
_plan: ClassVar[Optional[EnergyManagementPlan]] = None
# opimization solution of the latest energy management run
_optimization_solution: ClassVar[Optional[OptimizationSolution]] = None
# Solution of the genetic algorithm of latest energy management run with optimization
# For classic API
_genetic_solution: ClassVar[Optional[GeneticSolution]] = None
# energy management lock (for energy management run)
_run_lock: ClassVar[Lock] = Lock()
_last_datetime: ClassVar[Optional[DateTime]] = None
@computed_field # type: ignore[prop-decorator]
@property
@@ -54,15 +128,9 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
EnergyManagement.set_start_datetime()
return EnergyManagement._start_datetime
@computed_field # type: ignore[prop-decorator]
@property
def last_run_datetime(self) -> Optional[DateTime]:
"""The datetime the last energy management was run."""
return EnergyManagement._last_run_datetime
@classmethod
def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime:
"""Set the start datetime for the next energy management run.
"""Set the start datetime for the next energy management cycle.
If no datetime is provided, the current datetime is used.
@@ -81,208 +149,140 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
return cls._start_datetime
@classmethod
def plan(cls) -> Optional[EnergyManagementPlan]:
"""Get the latest energy management plan.
# -------------------------
# TODO: Take from prediction
# -------------------------
Returns:
Optional[EnergyManagementPlan]: The latest energy management plan or None.
"""
return cls._plan
load_energy_array: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="An array of floats representing the total load (consumption) in watts for different time intervals.",
)
pv_prediction_wh: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals.",
)
elect_price_hourly: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="An array of floats representing the electricity price in euros per watt-hour for different time intervals.",
)
elect_revenue_per_hour_arr: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
description="An array of floats representing the feed-in compensation in euros per watt-hour.",
)
@classmethod
def optimization_solution(cls) -> Optional[OptimizationSolution]:
"""Get the latest optimization solution.
# -------------------------
# TODO: Move to devices
# -------------------------
Returns:
Optional[OptimizationSolution]: The latest optimization solution.
"""
return cls._optimization_solution
battery: Optional[Battery] = Field(default=None, description="TBD.")
ev: Optional[Battery] = Field(default=None, description="TBD.")
home_appliance: Optional[HomeAppliance] = Field(default=None, description="TBD.")
inverter: Optional[Inverter] = Field(default=None, description="TBD.")
@classmethod
def genetic_solution(cls) -> Optional[GeneticSolution]:
"""Get the latest solution of the genetic algorithm.
# -------------------------
# TODO: Move to devices
# -------------------------
Returns:
Optional[GeneticSolution]: The latest solution of the genetic algorithm.
"""
return cls._genetic_solution
ac_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD")
@classmethod
def _run(
cls,
start_datetime: Optional[DateTime] = None,
mode: Optional[EnergyManagementMode] = None,
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
genetic_individuals: Optional[int] = None,
genetic_seed: Optional[int] = None,
force_enable: Optional[bool] = False,
force_update: Optional[bool] = False,
) -> None:
"""Run the energy management.
This method initializes the energy management run by setting its
start datetime, updating predictions, and optionally starting
optimization depending on the selected mode or configuration.
Args:
start_datetime (DateTime, optional): The starting timestamp
of the energy management run. Defaults to the current datetime
if not provided.
mode (EnergyManagementMode, optional): The management mode to use. Must be one of:
- "OPTIMIZATION": Runs the optimization process.
- "PREDICTION": Updates the forecast without optimization.
Defaults to the mode defined in the current configuration.
genetic_parameters (GeneticOptimizationParameters, optional): The
parameter set for the genetic algorithm. If not provided, it will
be constructed based on the current configuration and predictions.
genetic_individuals (int, optional): The number of individuals for the
genetic algorithm. Defaults to the algorithm's internal default (400)
if not specified.
genetic_seed (int, optional): The seed for the genetic algorithm. Defaults
to the algorithm's internal random seed if not specified.
force_enable (bool, optional): If True, bypasses any disabled state
to force the update process. This is mostly applicable to
prediction providers.
force_update (bool, optional): If True, forces data to be refreshed
even if a cached version is still valid.
Returns:
None
"""
# Ensure there is only one optimization/ energy management run at a time
if mode not in (None, "PREDICTION", "OPTIMIZATION"):
raise ValueError(f"Unknown energy management mode {mode}.")
logger.info("Starting energy management run.")
# Remember/ set the start datetime of this energy management run.
# None leads
cls.set_start_datetime(start_datetime)
# Throw away any memory cached results of the last energy management run.
CacheEnergyManagementStore().clear()
if mode is None:
mode = cls.config.ems.mode
if mode is None or mode == "PREDICTION":
# Update the predictions
cls.prediction.update_data(force_enable=force_enable, force_update=force_update)
logger.info("Energy management run done (predictions updated)")
def __init__(self, *args: Any, **kwargs: Any) -> None:
if hasattr(self, "_initialized"):
return
super().__init__(*args, **kwargs)
# Prepare optimization parameters
# This also creates default configurations for missing values and updates the predictions
logger.info(
"Starting energy management prediction update and optimzation parameter preparation."
)
if genetic_parameters is None:
genetic_parameters = GeneticOptimizationParameters.prepare()
if not genetic_parameters:
logger.error(
"Energy management run canceled. Could not prepare optimisation parameters."
)
return
# Take values from config if not given
if genetic_individuals is None:
genetic_individuals = cls.config.optimization.genetic.individuals
if genetic_seed is None:
genetic_seed = cls.config.optimization.genetic.seed
if cls._start_datetime is None: # Make mypy happy - already set by us
raise RuntimeError("Start datetime not set.")
logger.info("Starting energy management optimization.")
try:
optimization = GeneticOptimization(
verbose=bool(cls.config.server.verbose),
fixed_seed=genetic_seed,
)
solution = optimization.optimierung_ems(
start_hour=cls._start_datetime.hour,
parameters=genetic_parameters,
ngen=genetic_individuals,
)
except:
logger.exception("Energy management optimization failed.")
return
# Make genetic solution public
cls._genetic_solution = solution
# Make optimization solution public
cls._optimization_solution = solution.optimization_solution()
# Make plan public
cls._plan = solution.energy_management_plan()
logger.debug("Energy management genetic solution:\n{}", cls._genetic_solution)
logger.debug("Energy management optimization solution:\n{}", cls._optimization_solution)
logger.debug("Energy management plan:\n{}", cls._plan)
logger.info("Energy management run done (optimization updated)")
async def run(
def set_parameters(
self,
start_datetime: Optional[DateTime] = None,
mode: Optional[EnergyManagementMode] = None,
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
genetic_individuals: Optional[int] = None,
genetic_seed: Optional[int] = None,
parameters: EnergyManagementParameters,
ev: Optional[Battery] = None,
home_appliance: Optional[HomeAppliance] = None,
inverter: Optional[Inverter] = None,
) -> None:
self.load_energy_array = np.array(parameters.gesamtlast, float)
self.pv_prediction_wh = np.array(parameters.pv_prognose_wh, float)
self.elect_price_hourly = np.array(parameters.strompreis_euro_pro_wh, float)
self.elect_revenue_per_hour_arr = (
parameters.einspeiseverguetung_euro_pro_wh
if isinstance(parameters.einspeiseverguetung_euro_pro_wh, list)
else np.full(
len(self.load_energy_array), parameters.einspeiseverguetung_euro_pro_wh, float
)
)
if inverter:
self.battery = inverter.battery
else:
self.battery = None
self.ev = ev
self.home_appliance = home_appliance
self.inverter = inverter
self.ac_charge_hours = np.full(self.config.prediction.hours, 0.0)
self.dc_charge_hours = np.full(self.config.prediction.hours, 1.0)
self.ev_charge_hours = np.full(self.config.prediction.hours, 0.0)
def set_akku_discharge_hours(self, ds: np.ndarray) -> None:
if self.battery:
self.battery.set_discharge_per_hour(ds)
def set_akku_ac_charge_hours(self, ds: np.ndarray) -> None:
self.ac_charge_hours = ds
def set_akku_dc_charge_hours(self, ds: np.ndarray) -> None:
self.dc_charge_hours = ds
def set_ev_charge_hours(self, ds: np.ndarray) -> None:
self.ev_charge_hours = ds
def set_home_appliance_start(self, ds: int, global_start_hour: int = 0) -> None:
if self.home_appliance:
self.home_appliance.set_starting_time(ds, global_start_hour=global_start_hour)
def reset(self) -> None:
if self.ev:
self.ev.reset()
if self.battery:
self.battery.reset()
def run(
self,
start_hour: Optional[int] = None,
force_enable: Optional[bool] = False,
force_update: Optional[bool] = False,
) -> None:
"""Run the energy management.
"""Run energy management.
This method initializes the energy management run by setting its
start datetime, updating predictions, and optionally starting
optimization depending on the selected mode or configuration.
Sets `start_datetime` to current hour, updates the configuration and the prediction, and
starts simulation at current hour.
Args:
start_datetime (DateTime, optional): The starting timestamp
of the energy management run. Defaults to the current datetime
if not provided.
mode (EnergyManagementMode, optional): The management mode to use. Must be one of:
- "OPTIMIZATION": Runs the optimization process.
- "PREDICTION": Updates the forecast without optimization.
Defaults to the mode defined in the current configuration.
genetic_parameters (GeneticOptimizationParameters, optional): The
parameter set for the genetic algorithm. If not provided, it will
be constructed based on the current configuration and predictions.
genetic_individuals (int, optional): The number of individuals for the
genetic algorithm. Defaults to the algorithm's internal default (400)
if not specified.
genetic_seed (int, optional): The seed for the genetic algorithm. Defaults
to the algorithm's internal random seed if not specified.
force_enable (bool, optional): If True, bypasses any disabled state
to force the update process. This is mostly applicable to
prediction providers.
force_update (bool, optional): If True, forces data to be refreshed
even if a cached version is still valid.
Returns:
None
start_hour (int, optional): Hour to take as start time for the energy management. Defaults
to now.
force_enable (bool, optional): If True, forces to update even if disabled. This
is mostly relevant to prediction providers.
force_update (bool, optional): If True, forces to update the data even if still cached.
"""
async with self._run_lock:
loop = get_running_loop()
# Create a partial function with parameters "baked in"
func = partial(
EnergyManagement._run,
start_datetime=start_datetime,
mode=mode,
genetic_parameters=genetic_parameters,
genetic_individuals=genetic_individuals,
genetic_seed=genetic_seed,
force_enable=force_enable,
force_update=force_update,
)
# Run optimization in background thread to avoid blocking event loop
await loop.run_in_executor(executor, func)
# Throw away any cached results of the last run.
CacheUntilUpdateStore().clear()
self.set_start_hour(start_hour=start_hour)
async def manage_energy(self) -> None:
# Check for run definitions
if self.start_datetime is None:
error_msg = "Start datetime unknown."
logger.error(error_msg)
raise ValueError(error_msg)
if self.config.prediction.hours is None:
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."
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.
def manage_energy(self) -> None:
"""Repeating task for managing energy.
This task should be executed by the server regularly (e.g., every 10 seconds)
@@ -301,47 +301,217 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
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_run_datetime is None:
if EnergyManagement._last_datetime is None:
# Never run before
try:
# Remember energy run datetime.
EnergyManagement._last_run_datetime = current_datetime
# Try to run a first energy management. May fail due to config incomplete.
await self.run()
self.run()
# Remember energy run datetime.
EnergyManagement._last_datetime = current_datetime
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
message = f"EOS init: {e}\n{trace}"
message = f"EOS init: {e}"
logger.error(message)
return
if interval is None or interval == float("nan"):
if self.config.ems.interval is None or self.config.ems.interval == float("nan"):
# No Repetition
return
if (
compare_datetimes(current_datetime, EnergyManagement._last_run_datetime).time_diff
< interval
compare_datetimes(current_datetime, self._last_datetime).time_diff
< self.config.ems.interval
):
# Wait for next run
return
try:
await self.run()
self.run()
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
message = f"EOS run: {e}\n{trace}"
message = f"EOS run: {e}"
logger.error(message)
# Remember the energy management run - keep on interval even if we missed some intervals
while (
compare_datetimes(current_datetime, EnergyManagement._last_run_datetime).time_diff
>= interval
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
>= self.config.ems.interval
):
EnergyManagement._last_run_datetime = EnergyManagement._last_run_datetime.add(
seconds=interval
EnergyManagement._last_datetime.add(seconds=self.config.ems.interval)
def set_start_hour(self, start_hour: Optional[int] = None) -> None:
"""Sets start datetime to given hour.
Args:
start_hour (int, optional): Hour to take as start time for the energy management. Defaults
to now.
"""
if start_hour is None:
self.set_start_datetime()
else:
start_datetime = to_datetime().set(hour=start_hour, minute=0, second=0, microsecond=0)
self.set_start_datetime(start_datetime)
def simulate_start_now(self) -> dict[str, Any]:
start_hour = to_datetime().now().hour
return self.simulate(start_hour)
def simulate(self, start_hour: int) -> dict[str, Any]:
"""Simulate energy usage and costs for the given start hour.
akku_soc_pro_stunde begin of the hour, initial hour state!
last_wh_pro_stunde integral of last hour (end state)
"""
# Check for simulation integrity
required_attrs = [
"load_energy_array",
"pv_prediction_wh",
"elect_price_hourly",
"ev_charge_hours",
"ac_charge_hours",
"dc_charge_hours",
"elect_revenue_per_hour_arr",
]
missing_data = [
attr.replace("_", " ").title() for attr in required_attrs if getattr(self, attr) is None
]
if missing_data:
logger.error("Mandatory data missing - %s", ", ".join(missing_data))
raise ValueError(f"Mandatory data missing: {', '.join(missing_data)}")
# Pre-fetch data
load_energy_array = np.array(self.load_energy_array)
pv_prediction_wh = np.array(self.pv_prediction_wh)
elect_price_hourly = np.array(self.elect_price_hourly)
ev_charge_hours = np.array(self.ev_charge_hours)
ac_charge_hours = np.array(self.ac_charge_hours)
dc_charge_hours = np.array(self.dc_charge_hours)
elect_revenue_per_hour_arr = np.array(self.elect_revenue_per_hour_arr)
# Fetch objects
battery = self.battery
assert battery # to please mypy
ev = self.ev
home_appliance = self.home_appliance
inverter = self.inverter
if not (len(load_energy_array) == len(pv_prediction_wh) == len(elect_price_hourly)):
error_msg = f"Array sizes do not match: Load Curve = {len(load_energy_array)}, PV Forecast = {len(pv_prediction_wh)}, Electricity Price = {len(elect_price_hourly)}"
logger.error(error_msg)
raise ValueError(error_msg)
end_hour = len(load_energy_array)
total_hours = end_hour - start_hour
# Pre-allocate arrays for the results, optimized for speed
loads_energy_per_hour = np.full((total_hours), np.nan)
feedin_energy_per_hour = np.full((total_hours), np.nan)
consumption_energy_per_hour = np.full((total_hours), np.nan)
costs_per_hour = np.full((total_hours), np.nan)
revenue_per_hour = np.full((total_hours), np.nan)
soc_per_hour = np.full((total_hours), np.nan)
soc_ev_per_hour = np.full((total_hours), np.nan)
losses_wh_per_hour = np.full((total_hours), np.nan)
home_appliance_wh_per_hour = np.full((total_hours), np.nan)
electricity_price_per_hour = np.full((total_hours), np.nan)
# Set initial state
soc_per_hour[0] = battery.current_soc_percentage()
if ev:
soc_ev_per_hour[0] = ev.current_soc_percentage()
for hour in range(start_hour, end_hour):
hour_idx = hour - start_hour
# save begin states
soc_per_hour[hour_idx] = battery.current_soc_percentage()
if ev:
soc_ev_per_hour[hour_idx] = ev.current_soc_percentage()
# Accumulate loads and PV generation
consumption = load_energy_array[hour]
losses_wh_per_hour[hour_idx] = 0.0
# Home appliances
if home_appliance:
ha_load = home_appliance.get_load_for_hour(hour)
consumption += ha_load
home_appliance_wh_per_hour[hour_idx] = ha_load
# E-Auto handling
if ev and ev_charge_hours[hour] > 0:
loaded_energy_ev, verluste_eauto = ev.charge_energy(
None, hour, relative_power=ev_charge_hours[hour]
)
consumption += loaded_energy_ev
losses_wh_per_hour[hour_idx] += verluste_eauto
# Process inverter logic
energy_feedin_grid_actual = energy_consumption_grid_actual = losses = eigenverbrauch = (
0.0
)
hour_ac_charge = ac_charge_hours[hour]
hour_dc_charge = dc_charge_hours[hour]
hourly_electricity_price = elect_price_hourly[hour]
hourly_energy_revenue = elect_revenue_per_hour_arr[hour]
battery.set_charge_allowed_for_hour(hour_dc_charge, hour)
if inverter:
energy_produced = pv_prediction_wh[hour]
(
energy_feedin_grid_actual,
energy_consumption_grid_actual,
losses,
eigenverbrauch,
) = inverter.process_energy(energy_produced, consumption, hour)
# AC PV Battery Charge
if hour_ac_charge > 0.0:
battery.set_charge_allowed_for_hour(1, hour)
battery_charged_energy_actual, battery_losses_actual = battery.charge_energy(
None, hour, relative_power=hour_ac_charge
)
total_battery_energy = battery_charged_energy_actual + battery_losses_actual
consumption += total_battery_energy
energy_consumption_grid_actual += total_battery_energy
losses_wh_per_hour[hour_idx] += battery_losses_actual
# Update hourly arrays
feedin_energy_per_hour[hour_idx] = energy_feedin_grid_actual
consumption_energy_per_hour[hour_idx] = energy_consumption_grid_actual
losses_wh_per_hour[hour_idx] += losses
loads_energy_per_hour[hour_idx] = consumption
electricity_price_per_hour[hour_idx] = hourly_electricity_price
# Financial calculations
costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price
revenue_per_hour[hour_idx] = energy_feedin_grid_actual * hourly_energy_revenue
total_cost = np.nansum(costs_per_hour)
total_losses = np.nansum(losses_wh_per_hour)
total_revenue = np.nansum(revenue_per_hour)
# Prepare output dictionary
return {
"Last_Wh_pro_Stunde": loads_energy_per_hour,
"Netzeinspeisung_Wh_pro_Stunde": feedin_energy_per_hour,
"Netzbezug_Wh_pro_Stunde": consumption_energy_per_hour,
"Kosten_Euro_pro_Stunde": costs_per_hour,
"akku_soc_pro_stunde": soc_per_hour,
"Einnahmen_Euro_pro_Stunde": revenue_per_hour,
"Gesamtbilanz_Euro": total_cost - total_revenue,
"EAuto_SoC_pro_Stunde": soc_ev_per_hour,
"Gesamteinnahmen_Euro": total_revenue,
"Gesamtkosten_Euro": total_cost,
"Verluste_Pro_Stunde": losses_wh_per_hour,
"Gesamt_Verluste": total_losses,
"Home_appliance_wh_per_hour": home_appliance_wh_per_hour,
"Electricity_price": electricity_price_per_hour,
}
# Initialize the Energy Management System, it is a singleton.

View File

@@ -3,7 +3,6 @@
Kept in an extra module to avoid cyclic dependencies on package import.
"""
from enum import Enum
from typing import Optional
from pydantic import Field
@@ -11,36 +10,17 @@ from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
class EnergyManagementMode(str, Enum):
"""Energy management mode."""
PREDICTION = "PREDICTION"
OPTIMIZATION = "OPTIMIZATION"
class EnergyManagementCommonSettings(SettingsBaseModel):
"""Energy Management Configuration."""
startup_delay: float = Field(
default=5,
ge=1,
json_schema_extra={
"description": "Startup delay in seconds for EOS energy management runs."
},
description="Startup delay in seconds for EOS energy management runs.",
)
interval: Optional[float] = Field(
default=None,
json_schema_extra={
"description": "Intervall in seconds between EOS energy management runs.",
"examples": ["300"],
},
)
mode: Optional[EnergyManagementMode] = Field(
default=None,
json_schema_extra={
"description": "Energy management mode [OPTIMIZATION | PREDICTION].",
"examples": ["OPTIMIZATION", "PREDICTION"],
},
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,245 +1,95 @@
"""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.
"""
# Skip DEBUG logs from matplotlib - very noisy
if record.name.startswith("matplotlib") and record.levelno <= pylogging.DEBUG:
return
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
# This is already supported by pydantic-settings in LoggingCommonSettings, however in case
# loading the config itself fails and to set the level before we load the config, we set it here manually.
if logging_level is None and (env_level := os.getenv("EOS_LOGGING__LEVEL")) is not None:
logging_level = env_level
if logging_level is not None:
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,60 +3,41 @@
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):
"""Logging Configuration."""
console_level: Optional[str] = Field(
level: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Logging level when logging to console.",
"examples": LOGGING_LEVELS,
},
description="EOS default logging level.",
examples=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"],
)
file_level: Optional[str] = Field(
default=None,
json_schema_extra={
"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

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +0,0 @@
"""Version information for akkudoktoreos."""
import hashlib
import re
from fnmatch import fnmatch
from pathlib import Path
from typing import Optional
# For development add `+dev` to previous release
# For release omit `+dev`.
VERSION_BASE = "0.2.0+dev"
# Project hash of relevant files
HASH_EOS = ""
# ------------------------------
# Helpers for version generation
# ------------------------------
def is_excluded_dir(path: Path, excluded_dir_patterns: set[str]) -> bool:
"""Check whether a directory should be excluded based on name patterns."""
return any(fnmatch(path.name, pattern) for pattern in excluded_dir_patterns)
def hash_tree(
paths: list[Path],
allowed_suffixes: set[str],
excluded_dir_patterns: set[str],
excluded_files: Optional[set[Path]] = None,
) -> str:
"""Return SHA256 hash for files under `paths`.
Restricted by suffix, excluding excluded directory patterns and excluded_files.
"""
h = hashlib.sha256()
excluded_files = excluded_files or set()
for root in paths:
if not root.exists():
raise ValueError(f"Root path does not exist: {root}")
for p in sorted(root.rglob("*")):
# Skip excluded directories
if p.is_dir() and is_excluded_dir(p, excluded_dir_patterns):
continue
# Skip files inside excluded directories
if any(is_excluded_dir(parent, excluded_dir_patterns) for parent in p.parents):
continue
# Skip excluded files
if p.resolve() in excluded_files:
continue
# Hash only allowed file types
if p.is_file() and p.suffix.lower() in allowed_suffixes:
h.update(p.read_bytes())
digest = h.hexdigest()
return digest
def _version_hash() -> str:
"""Calculate project hash.
Only package file ins src/akkudoktoreos can be hashed to make it work also for packages.
"""
DIR_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
# Allowed file suffixes to consider
ALLOWED_SUFFIXES: set[str] = {".py", ".md", ".json"}
# Directory patterns to exclude (glob-like)
EXCLUDED_DIR_PATTERNS: set[str] = {"*_autosum", "*__pycache__", "*_generated"}
# Files to exclude
EXCLUDED_FILES: set[Path] = set()
# Directories whose changes shall be part of the project hash
watched_paths = [DIR_PACKAGE_ROOT]
hash_current = hash_tree(
watched_paths, ALLOWED_SUFFIXES, EXCLUDED_DIR_PATTERNS, excluded_files=EXCLUDED_FILES
)
return hash_current
def _version_calculate() -> str:
"""Compute version."""
global HASH_EOS
HASH_EOS = _version_hash()
if VERSION_BASE.endswith("+dev"):
return f"{VERSION_BASE}.{HASH_EOS[:6]}"
else:
return VERSION_BASE
# ---------------------------
# Project version information
# ----------------------------
# The version
__version__ = _version_calculate()
# -------------------
# Version info access
# -------------------
# Regular expression to split the version string into pieces
VERSION_RE = re.compile(
r"""
^(?P<base>\d+\.\d+\.\d+) # x.y.z
(?:\+ # +dev.hash starts here
(?:
(?P<dev>dev) # literal 'dev'
(?:\.(?P<hash>[A-Za-z0-9]+))? # optional .hash
)
)?
$
""",
re.VERBOSE,
)
def version() -> dict[str, Optional[str]]:
"""Parses the version string.
The version string shall be of the form:
x.y.z
x.y.z+dev
x.y.z+dev.HASH
Returns:
.. code-block:: python
{
"version": "0.2.0+dev.a96a65",
"base": "x.y.z",
"dev": "dev" or None,
"hash": "<hash>" or None,
}
"""
global __version__
match = VERSION_RE.match(__version__)
if not match:
raise ValueError(f"Invalid version format: {version}")
info = match.groupdict()
info["version"] = __version__
return info

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,249 @@
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,
DeviceParameters,
)
from akkudoktoreos.utils.utils import NumpyEncoder
logger = get_logger(__name__)
def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None:
description = "Maximum charging power in watts."
return Field(
default=5000,
gt=0,
description=description,
)
def initial_soc_percentage_field(description: str) -> int:
return Field(default=0, ge=0, le=100, description=description, examples=[42])
def discharging_efficiency_field(default_value: float) -> float:
return Field(
default=default_value,
gt=0,
le=1,
description="A float representing the discharge efficiency of the battery.",
)
class BaseBatteryParameters(DeviceParameters):
"""Battery Device Simulation Configuration."""
device_id: str = Field(description="ID of battery", examples=["battery1"])
capacity_wh: int = Field(
gt=0,
description="An integer representing the capacity of the battery in watt-hours.",
examples=[8000],
)
charging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
description="A float representing the charging efficiency of the battery.",
)
discharging_efficiency: float = discharging_efficiency_field(0.88)
max_charge_power_w: Optional[float] = max_charging_power_field()
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
)
min_soc_percentage: int = Field(
default=0,
ge=0,
le=100,
description="An integer representing the minimum state of charge (SOC) of the battery in percentage.",
examples=[10],
)
max_soc_percentage: int = Field(
default=100,
ge=0,
le=100,
description="An integer representing the maximum state of charge (SOC) of the battery in percentage.",
)
class SolarPanelBatteryParameters(BaseBatteryParameters):
max_charge_power_w: Optional[float] = max_charging_power_field()
class ElectricVehicleParameters(BaseBatteryParameters):
"""Battery Electric Vehicle Device Simulation Configuration."""
device_id: str = Field(description="ID of electric vehicle", examples=["ev1"])
discharging_efficiency: float = discharging_efficiency_field(1.0)
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
class ElectricVehicleResult(DeviceOptimizeResult):
"""Result class containing information related to the electric vehicle's charging and discharging behavior."""
device_id: str = Field(description="ID of electric vehicle", examples=["ev1"])
charge_array: list[float] = Field(
description="Hourly charging status (0 for no charging, 1 for charging)."
)
discharge_array: list[int] = Field(
description="Hourly discharging status (0 for no discharging, 1 for discharging)."
)
discharging_efficiency: float = Field(description="The discharge efficiency as a float..")
capacity_wh: int = Field(description="Capacity of the EVs battery in watt-hours.")
charging_efficiency: float = Field(description="Charging efficiency as a float..")
max_charge_power_w: int = Field(description="Maximum charging power in watts.")
soc_wh: float = Field(
description="State of charge of the battery in watt-hours at the start of the simulation."
)
initial_soc_percentage: int = Field(
description="State of charge at the start of the simulation in percentage."
)
@field_validator("discharge_array", "charge_array", mode="before")
def convert_numpy(cls, field: Any) -> Any:
return NumpyEncoder.convert_numpy(field)[0]
class Battery(DeviceBase):
"""Represents a battery device with methods to simulate energy charging and discharging."""
def __init__(self, parameters: Optional[BaseBatteryParameters] = None):
self.parameters: Optional[BaseBatteryParameters] = None
super().__init__(parameters)
def _setup(self) -> None:
"""Sets up the battery parameters based on configuration or provided 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
self.discharging_efficiency = self.parameters.discharging_efficiency
# Only assign for storage battery
self.min_soc_percentage = (
self.parameters.min_soc_percentage
if isinstance(self.parameters, SolarPanelBatteryParameters)
else 0
)
self.max_soc_percentage = self.parameters.max_soc_percentage
# Initialize state of charge
if self.parameters.max_charge_power_w is not None:
self.max_charge_power_w = self.parameters.max_charge_power_w
else:
self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
self.discharge_array = np.full(self.hours, 1)
self.charge_array = np.full(self.hours, 1)
self.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh
self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh
self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh
def to_dict(self) -> dict[str, Any]:
"""Converts the object to a dictionary representation."""
return {
"device_id": self.device_id,
"capacity_wh": self.capacity_wh,
"initial_soc_percentage": self.initial_soc_percentage,
"soc_wh": self.soc_wh,
"hours": self.hours,
"discharge_array": self.discharge_array,
"charge_array": self.charge_array,
"charging_efficiency": self.charging_efficiency,
"discharging_efficiency": self.discharging_efficiency,
"max_charge_power_w": self.max_charge_power_w,
}
def reset(self) -> None:
"""Resets the battery state to its initial values."""
self.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh
self.soc_wh = min(max(self.soc_wh, self.min_soc_wh), self.max_soc_wh)
self.discharge_array = np.full(self.hours, 1)
self.charge_array = np.full(self.hours, 1)
def set_discharge_per_hour(self, discharge_array: np.ndarray) -> None:
"""Sets the discharge values for each hour."""
if len(discharge_array) != self.hours:
raise ValueError(f"Discharge array must have exactly {self.hours} elements.")
self.discharge_array = np.array(discharge_array)
def set_charge_per_hour(self, charge_array: np.ndarray) -> None:
"""Sets the charge values for each hour."""
if len(charge_array) != self.hours:
raise ValueError(f"Charge array must have exactly {self.hours} elements.")
self.charge_array = np.array(charge_array)
def set_charge_allowed_for_hour(self, charge: float, hour: int) -> None:
"""Sets the charge for a specific hour."""
if hour >= self.hours:
raise ValueError(f"Hour {hour} is out of range. Must be less than {self.hours}.")
self.charge_array[hour] = charge
def current_soc_percentage(self) -> float:
"""Calculates the current state of charge in percentage."""
return (self.soc_wh / self.capacity_wh) * 100
def discharge_energy(self, wh: float, hour: int) -> tuple[float, float]:
"""Discharges energy from the battery."""
if self.discharge_array[hour] == 0:
return 0.0, 0.0
max_possible_discharge_wh = (self.soc_wh - self.min_soc_wh) * self.discharging_efficiency
max_possible_discharge_wh = max(max_possible_discharge_wh, 0.0)
max_possible_discharge_wh = min(
max_possible_discharge_wh, self.max_charge_power_w
) # TODO make a new cfg variable max_discharge_power_w
actual_discharge_wh = min(wh, max_possible_discharge_wh)
actual_withdrawal_wh = (
actual_discharge_wh / self.discharging_efficiency
if self.discharging_efficiency > 0
else 0.0
)
self.soc_wh -= actual_withdrawal_wh
self.soc_wh = max(self.soc_wh, self.min_soc_wh)
losses_wh = actual_withdrawal_wh - actual_discharge_wh
return actual_discharge_wh, losses_wh
def charge_energy(
self, wh: Optional[float], hour: int, relative_power: float = 0.0
) -> tuple[float, float]:
"""Charges energy into the battery."""
if hour is not None and self.charge_array[hour] == 0:
return 0.0, 0.0 # Charging not allowed in this hour
if relative_power > 0.0:
wh = self.max_charge_power_w * relative_power
wh = wh if wh is not None else self.max_charge_power_w
max_possible_charge_wh = (
(self.max_soc_wh - self.soc_wh) / self.charging_efficiency
if self.charging_efficiency > 0
else 0.0
)
max_possible_charge_wh = max(max_possible_charge_wh, 0.0)
effective_charge_wh = min(wh, max_possible_charge_wh)
charged_wh = effective_charge_wh * self.charging_efficiency
self.soc_wh += charged_wh
self.soc_wh = min(self.soc_wh, self.max_soc_wh)
losses_wh = effective_charge_wh - charged_wh
return charged_wh, losses_wh
def current_energy_content(self) -> float:
"""Returns the current usable energy in the battery."""
usable_energy = (self.soc_wh - self.min_soc_wh) * self.discharging_efficiency
return max(usable_energy, 0.0)

View File

@@ -1,441 +1,48 @@
"""General configuration settings for simulated devices for optimization."""
import json
import re
from typing import Any, Optional, TextIO, cast
import numpy as np
from loguru import logger
from numpydantic import NDArray, Shape
from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin
from akkudoktoreos.core.emplan import ResourceStatus
from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel
from akkudoktoreos.devices.devicesabc import DevicesBaseSettings
from akkudoktoreos.utils.datetimeutil import DateTime, TimeWindowSequence, to_datetime
# Default charge rates for battery
BATTERY_DEFAULT_CHARGE_RATES = np.linspace(0.0, 1.0, 11) # 0.0, 0.1, ..., 1.0
class BatteriesCommonSettings(DevicesBaseSettings):
"""Battery devices base settings."""
capacity_wh: int = Field(
default=8000, gt=0, json_schema_extra={"description": "Capacity [Wh].", "examples": [8000]}
)
charging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "Charging efficiency [0.01 ... 1.00].",
"examples": [0.88],
},
)
discharging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "Discharge efficiency [0.01 ... 1.00].",
"examples": [0.88],
},
)
levelized_cost_of_storage_kwh: float = Field(
default=0.0,
json_schema_extra={
"description": "Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [€/kWh].",
"examples": [0.12],
},
)
max_charge_power_w: Optional[float] = Field(
default=5000,
gt=0,
json_schema_extra={"description": "Maximum charging power [W].", "examples": [5000]},
)
min_charge_power_w: Optional[float] = Field(
default=50,
gt=0,
json_schema_extra={"description": "Minimum charging power [W].", "examples": [50]},
)
charge_rates: Optional[NDArray[Shape["*"], float]] = Field(
default=BATTERY_DEFAULT_CHARGE_RATES,
json_schema_extra={
"description": (
"Charge rates as factor of maximum charging power [0.00 ... 1.00]. "
"None triggers fallback to default charge-rates."
),
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
min_soc_percentage: int = Field(
default=0,
ge=0,
le=100,
json_schema_extra={
"description": (
"Minimum state of charge (SOC) as percentage of capacity [%]. "
"This is the target SoC for charging"
),
"examples": [10],
},
)
max_soc_percentage: int = Field(
default=100,
ge=0,
le=100,
json_schema_extra={
"description": "Maximum state of charge (SOC) as percentage of capacity [%].",
"examples": [100],
},
)
@field_validator("charge_rates", mode="before")
def validate_and_sort_charge_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
# None means fallback to default values
if v is None:
return BATTERY_DEFAULT_CHARGE_RATES.copy()
# Convert to numpy array
if isinstance(v, str):
# Remove brackets and split by comma or whitespace
numbers = re.split(r"[,\s]+", v.strip("[]"))
# Filter out any empty strings and convert to floats
arr = np.array([float(x) for x in numbers if x])
else:
arr = np.array(v, dtype=float)
# Must not be empty
if arr.size == 0:
raise ValueError("charge_rates must contain at least one value.")
# Enforce bounds: 0.0 ≤ x ≤ 1.0
if (arr < 0.0).any() or (arr > 1.0).any():
raise ValueError("charge_rates must be within [0.0, 1.0].")
# Remove duplicates + sort
arr = np.unique(arr)
arr.sort()
return arr
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_soc_factor(self) -> str:
"""Measurement key for the battery state of charge (SoC) as factor of total capacity [0.0 ... 1.0]."""
return f"{self.device_id}-soc-factor"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l1_w(self) -> str:
"""Measurement key for the L1 power the battery is charged or discharged with [W]."""
return f"{self.device_id}-power-l1-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l2_w(self) -> str:
"""Measurement key for the L2 power the battery is charged or discharged with [W]."""
return f"{self.device_id}-power-l2-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l3_w(self) -> str:
"""Measurement key for the L3 power the battery is charged or discharged with [W]."""
return f"{self.device_id}-power-l3-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_3_phase_sym_w(self) -> str:
"""Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W]."""
return f"{self.device_id}-power-3-phase-sym-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Measurement keys for the battery stati that are measurements.
Battery SoC, power.
"""
keys: list[str] = [
self.measurement_key_soc_factor,
self.measurement_key_power_l1_w,
self.measurement_key_power_l2_w,
self.measurement_key_power_l3_w,
self.measurement_key_power_3_phase_sym_w,
]
return keys
class InverterCommonSettings(DevicesBaseSettings):
"""Inverter devices base settings."""
max_power_w: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={"description": "Maximum power [W].", "examples": [10000]},
)
battery_id: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "ID of battery controlled by this inverter.",
"examples": [None, "battery1"],
},
)
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Measurement keys for the inverter stati that are measurements."""
keys: list[str] = []
return keys
class HomeApplianceCommonSettings(DevicesBaseSettings):
"""Home Appliance devices base settings."""
consumption_wh: int = Field(
gt=0, json_schema_extra={"description": "Energy consumption [Wh].", "examples": [2000]}
)
duration_h: int = Field(
gt=0,
le=24,
json_schema_extra={"description": "Usage duration in hours [0 ... 24].", "examples": [1]},
)
time_windows: Optional[TimeWindowSequence] = Field(
default=None,
json_schema_extra={
"description": "Sequence of allowed time windows. Defaults to optimization general time window.",
"examples": [
{
"windows": [
{"start_time": "10:00", "duration": "2 hours"},
],
},
],
},
)
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Measurement keys for the home appliance stati that are measurements."""
keys: list[str] = []
return keys
class DevicesCommonSettings(SettingsBaseModel):
"""Base configuration for devices simulation settings."""
batteries: Optional[list[BatteriesCommonSettings]] = Field(
default=None,
json_schema_extra={
"description": "List of battery devices",
"examples": [[{"device_id": "battery1", "capacity_wh": 8000}]],
},
)
max_batteries: Optional[int] = Field(
default=None,
ge=0,
json_schema_extra={
"description": "Maximum number of batteries that can be set",
"examples": [1, 2],
},
)
electric_vehicles: Optional[list[BatteriesCommonSettings]] = Field(
default=None,
json_schema_extra={
"description": "List of electric vehicle devices",
"examples": [[{"device_id": "battery1", "capacity_wh": 8000}]],
},
)
max_electric_vehicles: Optional[int] = Field(
default=None,
ge=0,
json_schema_extra={
"description": "Maximum number of electric vehicles that can be set",
"examples": [1, 2],
},
)
inverters: Optional[list[InverterCommonSettings]] = Field(
default=None, json_schema_extra={"description": "List of inverters", "examples": [[]]}
)
max_inverters: Optional[int] = Field(
default=None,
ge=0,
json_schema_extra={
"description": "Maximum number of inverters that can be set",
"examples": [1, 2],
},
)
home_appliances: Optional[list[HomeApplianceCommonSettings]] = Field(
default=None, json_schema_extra={"description": "List of home appliances", "examples": [[]]}
)
max_home_appliances: Optional[int] = Field(
default=None,
ge=0,
json_schema_extra={
"description": "Maximum number of home_appliances that can be set",
"examples": [1, 2],
},
)
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Return the measurement keys for the resource/ device stati that are measurements."""
keys: list[str] = []
if self.max_batteries and self.batteries:
for battery in self.batteries:
keys.extend(battery.measurement_keys)
if self.max_electric_vehicles and self.electric_vehicles:
for electric_vehicle in self.electric_vehicles:
keys.extend(electric_vehicle.measurement_keys)
return keys
# Type used for indexing: (resource_id, optional actuator_id)
class ResourceKey(PydanticBaseModel):
"""Key identifying a resource and optionally an actuator."""
resource_id: str
actuator_id: Optional[str] = None
model_config = ConfigDict(frozen=True)
def __hash__(self) -> int:
"""Returns a stable hash based on the resource_id and actuator_id.
Returns:
int: Hash value derived from the resource_id and actuator_id.
"""
return hash(self.resource_id + self.actuator_id if self.actuator_id else "")
def as_tuple(self) -> tuple[str, Optional[str]]:
"""Return the key as a tuple for internal dictionary indexing."""
return (self.resource_id, self.actuator_id)
def __eq__(self, other: Any) -> bool:
if not isinstance(other, ResourceKey):
return NotImplemented
return self.resource_id == other.resource_id and self.actuator_id == other.actuator_id
class ResourceRegistry(SingletonMixin, ConfigMixin, PydanticBaseModel):
"""Registry for collecting and retrieving device status reports for simulations.
Maintains the latest and optionally historical status reports for each resource.
"""
keep_history: bool = False
history_size: int = 100
latest: dict[ResourceKey, ResourceStatus] = Field(
default_factory=dict,
json_schema_extra={
"description": "Latest resource status that was reported per resource key.",
"example": [],
},
)
history: dict[ResourceKey, list[tuple[DateTime, ResourceStatus]]] = Field(
default_factory=dict,
json_schema_extra={
"description": "History of resource stati that were reported per resource key.",
"example": [],
},
)
@model_validator(mode="after")
def _enforce_history_limits(self) -> "ResourceRegistry":
"""Ensure history list lengths respect the history_size limit."""
if self.keep_history:
for key, records in self.history.items():
if len(records) > self.history_size:
self.history[key] = records[-self.history_size :]
return self
def update_status(self, key: ResourceKey, status: ResourceStatus) -> None:
"""Update the latest status and optionally store in history.
Args:
key (ResourceKey): Identifier for the resource.
status (ResourceStatus): Status report to store.
"""
self.latest[key] = status
if self.keep_history:
timestamp = getattr(status, "transition_timestamp", None) or to_datetime()
self.history.setdefault(key, []).append((timestamp, status))
if len(self.history[key]) > self.history_size:
self.history[key] = self.history[key][-self.history_size :]
def status_latest(self, key: ResourceKey) -> Optional[ResourceStatus]:
"""Retrieve the most recent status for a resource."""
return self.latest.get(key)
def status_history(self, key: ResourceKey) -> list[tuple[DateTime, ResourceStatus]]:
"""Retrieve historical status reports for a resource."""
if not self.keep_history:
raise RuntimeError("History tracking is disabled.")
return self.history.get(key, [])
def status_exists(self, key: ResourceKey) -> bool:
"""Check if a status report exists for the given resource.
Args:
key (ResourceKey): Identifier for the resource.
"""
return key in self.latest
def save(self) -> None:
"""Save the registry to file."""
# Make explicit cast to make mypy happy
cache_file = cast(
TextIO, CacheFileStore().create(key="resource_registry", mode="w+", suffix=".json")
)
cache_file.seek(0)
cache_file.write(self.model_dump_json(indent=4))
cache_file.truncate() # Important to remove leftover data!
def load(self) -> None:
"""Load registry state from file and update the current instance."""
cache_file = CacheFileStore().get(key="resource_registry")
if cache_file:
try:
cache_file.seek(0)
data = json.load(cache_file)
loaded = self.__class__.model_validate(data)
self.keep_history = loaded.keep_history
self.history_size = loaded.history_size
self.latest = loaded.latest
self.history = loaded.history
except Exception as e:
logger.error("Can not load resource registry: {}", e)
def get_resource_registry() -> ResourceRegistry:
"""Gets the EOS resource registry."""
return ResourceRegistry()
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):
if hasattr(self, "_initialized"):
return
super().__init__()
if settings is None:
settings = self.config.devices
if settings is None:
return
# initialize devices
if settings.batteries is not None:
for battery_params in settings.batteries:
self.add_device(Battery(battery_params))
if settings.inverters is not None:
for inverter_params in settings.inverters:
self.add_device(Inverter(inverter_params))
if settings.home_appliances is not None:
for home_appliance_params in settings.home_appliances:
self.add_device(HomeAppliance(home_appliance_params))
self.post_setup()
def post_setup(self) -> None:
for device in self.devices.values():
device.post_setup()
# Initialize the Devices simulation, it is a singleton.
devices = Devices()
def get_devices() -> Devices:
"""Gets the EOS Devices simulation."""
return devices

View File

@@ -1,133 +1,182 @@
"""Abstract and base classes for devices."""
from enum import StrEnum
from enum import Enum
from typing import Optional, Type
from pydantic import Field
from pendulum import DateTime
from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import (
ConfigMixin,
DevicesMixin,
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 DevicesBaseSettings(SettingsBaseModel):
"""Base devices setting."""
device_id: str = Field(
default="<unknown>",
json_schema_extra={
"description": "ID of device",
"examples": ["battery1", "ev1", "inverter1", "dishwasher"],
},
class DeviceParameters(ParametersBaseModel):
device_id: str = Field(description="ID of device", examples="device1")
hours: Optional[int] = Field(
default=None,
gt=0,
description="Number of prediction hours. Defaults to global config prediction hours.",
examples=[None],
)
class BatteryOperationMode(StrEnum):
"""Battery Operation Mode.
class DeviceOptimizeResult(ParametersBaseModel):
device_id: str = Field(description="ID of device", examples=["device1"])
hours: int = Field(gt=0, description="Number of hours in the simulation.", examples=[24])
Enumerates the operating modes of a battery in a home energy
management simulation. These modes require no direct awareness
of electricity prices or carbon intensity — higher-level
controllers or optimizers decide when to switch modes.
Modes
-----
- IDLE:
No charging or discharging.
class DeviceState(Enum):
UNINITIALIZED = 0
PREPARED = 1
INITIALIZED = 2
- SELF_CONSUMPTION:
Charge from local surplus and discharge to meet local demand.
- NON_EXPORT:
Charge from on-site or local surplus with the goal of
minimizing or preventing energy export to the external grid.
Discharging to the grid is not allowed.
class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
"""A mixin to manage start, end datetimes for devices data.
- PEAK_SHAVING:
Discharge during local demand peaks to reduce grid draw.
- GRID_SUPPORT_EXPORT:
Discharge to support the upstream grid when commanded.
- GRID_SUPPORT_IMPORT:
Charge from the grid when instructed to absorb excess supply.
- FREQUENCY_REGULATION:
Perform fast bidirectional power adjustments based on grid
frequency deviations.
- RAMP_RATE_CONTROL:
Smooth changes in local net load or generation.
- RESERVE_BACKUP:
Maintain a minimum state of charge for emergency use.
- OUTAGE_SUPPLY:
Discharge to power critical loads during a grid outage.
- FORCED_CHARGE:
Override all other logic and charge regardless of conditions.
- FORCED_DISCHARGE:
Override all other logic and discharge regardless of conditions.
- FAULT:
Battery is unavailable due to fault or error state.
The starting datetime for devices data generation is provided by the energy management
system. Device data cannot be computed if this value is `None`.
"""
IDLE = "IDLE"
SELF_CONSUMPTION = "SELF_CONSUMPTION"
NON_EXPORT = "NON_EXPORT"
PEAK_SHAVING = "PEAK_SHAVING"
GRID_SUPPORT_EXPORT = "GRID_SUPPORT_EXPORT"
GRID_SUPPORT_IMPORT = "GRID_SUPPORT_IMPORT"
FREQUENCY_REGULATION = "FREQUENCY_REGULATION"
RAMP_RATE_CONTROL = "RAMP_RATE_CONTROL"
RESERVE_BACKUP = "RESERVE_BACKUP"
OUTAGE_SUPPLY = "OUTAGE_SUPPLY"
FORCED_CHARGE = "FORCED_CHARGE"
FORCED_DISCHARGE = "FORCED_DISCHARGE"
FAULT = "FAULT"
# Computed field for end_datetime and keep_datetime
@computed_field # type: ignore[prop-decorator]
@property
def end_datetime(self) -> Optional[DateTime]:
"""Compute the end datetime based on the `start_datetime` and `hours`.
Ajusts the calculated end time if DST transitions occur within the prediction window.
Returns:
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
"""
if self.ems.start_datetime and self.config.prediction.hours:
end_datetime = self.ems.start_datetime + to_duration(
f"{self.config.prediction.hours} hours"
)
dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours
logger.debug(
f"Pre: {self.ems.start_datetime}..{end_datetime}: DST change: {dst_change}"
)
if dst_change < 0:
end_datetime = end_datetime + to_duration(f"{abs(int(dst_change))} hours")
elif dst_change > 0:
end_datetime = end_datetime - to_duration(f"{abs(int(dst_change))} hours")
logger.debug(
f"Pst: {self.ems.start_datetime}..{end_datetime}: DST change: {dst_change}"
)
return end_datetime
return None
@computed_field # type: ignore[prop-decorator]
@property
def total_hours(self) -> Optional[int]:
"""Compute the hours from `start_datetime` to `end_datetime`.
Returns:
Optional[pendulum.period]: The duration hours, or `None` if either datetime is unavailable.
"""
end_dt = self.end_datetime
if end_dt is None:
return None
duration = end_dt - self.ems.start_datetime
return int(duration.total_hours())
class ApplianceOperationMode(StrEnum):
"""Appliance operation modes.
class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin):
"""Base class for device simulations.
Modes
-----
- OFF:
Stop or prevent any active operation of the appliance.
Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute
`prediction`) and EOS device registry (attribute `devices`).
- RUN:
Start or continue normal operation of the appliance.
Behavior:
- Several initialization phases (setup, post_setup):
- setup: Initialize class attributes from DeviceParameters (pydantic input validation)
- post_setup: Set connections between devices
- NotImplemented:
- hooks during optimization
- DEFER:
Postpone operation to a later time window based on
scheduling or optimization criteria.
- PAUSE:
Temporarily suspend an ongoing operation, keeping the
option to resume later.
- RESUME:
Continue an operation that was previously paused or
deferred.
- LIMIT_POWER:
Run the appliance under reduced power constraints,
for example in response to load-management or
demand-response signals.
- FORCED_RUN:
Start or maintain operation even if constraints or
optimization strategies would otherwise delay or limit it.
- FAULT:
Appliance is unavailable due to fault or error state.
Notes:
- This class is base to concrete devices like battery, inverter, etc. that are used in optimization.
- Not a pydantic model for a low footprint during optimization.
"""
OFF = "OFF"
RUN = "RUN"
DEFER = "DEFER"
PAUSE = "PAUSE"
RESUME = "RESUME"
LIMIT_POWER = "LIMIT_POWER"
FORCED_RUN = "FORCED_RUN"
FAULT = "FAULT"
def __init__(self, parameters: Optional[DeviceParameters] = None):
self.device_id: str = "<invalid>"
self.parameters: Optional[DeviceParameters] = None
self.hours = -1
if self.total_hours is not None:
self.hours = self.total_hours
self.initialized = DeviceState.UNINITIALIZED
if parameters is not None:
self.setup(parameters)
def setup(self, parameters: DeviceParameters) -> None:
if self.initialized != DeviceState.UNINITIALIZED:
return
self.parameters = parameters
self.device_id = self.parameters.device_id
if self.parameters.hours is not None:
self.hours = self.parameters.hours
if self.hours < 0:
raise ValueError("hours is unset")
self._setup()
self.initialized = DeviceState.PREPARED
def post_setup(self) -> None:
if self.initialized.value >= DeviceState.INITIALIZED.value:
return
self._post_setup()
self.initialized = DeviceState.INITIALIZED
def _setup(self) -> None:
"""Implement custom setup in derived device classes."""
pass
def _post_setup(self) -> None:
"""Implement custom setup in derived device classes that is run when all devices are initialized."""
pass
class DevicesBase(DevicesStartEndMixin, PredictionMixin):
"""Base class for handling device data.
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
`prediction`).
"""
def __init__(self) -> None:
super().__init__()
self.devices: dict[str, "DeviceBase"] = dict()
def get_device_by_id(self, device_id: str) -> Optional["DeviceBase"]:
return self.devices.get(device_id)
def add_device(self, device: Optional["DeviceBase"]) -> None:
if device is None:
return
assert device.device_id not in self.devices, f"{device.device_id} already registered"
self.devices[device.device_id] = device
def remove_device(self, device: Type["DeviceBase"] | str) -> bool:
if isinstance(device, DeviceBase):
device = device.device_id
return self.devices.pop(device, None) is not None # type: ignore[arg-type]
def reset(self) -> None:
self.devices = dict()

View File

@@ -0,0 +1,81 @@
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."""
device_id: str = Field(description="ID of home appliance", examples=["dishwasher"])
consumption_wh: int = Field(
gt=0,
description="An integer representing the energy consumption of a household device in watt-hours.",
examples=[2000],
)
duration_h: int = Field(
gt=0,
description="An integer representing the usage duration of a household device in hours.",
examples=[3],
)
class HomeAppliance(DeviceBase):
def __init__(
self,
parameters: Optional[HomeApplianceParameters] = None,
):
self.parameters: Optional[HomeApplianceParameters] = None
super().__init__(parameters)
def _setup(self) -> None:
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
def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None:
"""Sets the start time of the device and generates the corresponding load curve.
:param start_hour: The hour at which the device should start.
"""
self.reset_load_curve()
# Check if the duration of use is within the available time frame
if start_hour + self.duration_h > self.hours:
raise ValueError("The duration of use exceeds the available time frame.")
if start_hour < global_start_hour:
raise ValueError("The start time is earlier than the available time frame.")
# Calculate power per hour based on total consumption and duration
power_per_hour = self.consumption_wh / self.duration_h # Convert to watt-hours
# Set the power for the duration of use in the load curve array
self.load_curve[start_hour : start_hour + self.duration_h] = power_per_hour
def reset_load_curve(self) -> None:
"""Resets the load curve."""
self.load_curve = np.zeros(self.hours)
def get_load_curve(self) -> np.ndarray:
"""Returns the current load curve."""
return self.load_curve
def get_load_for_hour(self, hour: int) -> float:
"""Returns the load for a specific hour.
:param hour: The hour for which the load is queried.
:return: The load in watts for the specified hour.
"""
if hour < 0 or hour >= self.hours:
raise ValueError("The specified hour is outside the available time frame.")
return self.load_curve[hour]
def get_latest_starting_point(self) -> int:
"""Returns the latest possible start time at which the device can still run completely."""
return self.hours - self.duration_h

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