mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-10-11 03:46:17 +00:00
Compare commits
205 Commits
translatio
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
20a9eb78d8 | ||
|
cdb8bb26a9 | ||
|
9a07b010ca | ||
|
a70a1f5ca0 | ||
|
86a8985235 | ||
|
d3ba5bb464 | ||
|
5df8a19f13 | ||
|
482227c0b1 | ||
|
0a6a6d5ad8 | ||
|
64db2a7c0e | ||
|
ff713ca854 | ||
|
2ec54e3a99 | ||
|
e3f271d1f3 | ||
|
3c0d0b32df | ||
|
e8086defd2 | ||
|
559699cdad | ||
|
a4cce85ebd | ||
|
d362aa7298 | ||
|
1f610ce21c | ||
|
c6884fb54c | ||
|
340ca49957 | ||
|
09ad75d47d | ||
|
5d1962aeab | ||
|
b8dc4363ed | ||
|
22809c1e9b | ||
|
e007a60c60 | ||
|
efd115e8d4 | ||
|
5881c1459a | ||
|
45a5ec9b66 | ||
|
4c13d561a2 | ||
|
b34c528609 | ||
|
7d18c3940e | ||
|
decae10434 | ||
|
cacb21529b | ||
|
eed7c9ce58 | ||
|
4a87c67909 | ||
|
8319fed71c | ||
|
82c36b1ecd | ||
|
0378fa0f52 | ||
|
f79ec947d6 | ||
|
a912c22752 | ||
|
370975fc11 | ||
|
ec8b226ef4 | ||
|
33c4a736fe | ||
|
02fac1229d | ||
|
4cc2ae0fb8 | ||
|
e8af112859 | ||
|
fde5758b92 | ||
|
d5b0a2bc78 | ||
|
d09fa40507 | ||
|
0c8a9efb65 | ||
|
dbed3d3884 | ||
|
8e69caba73 | ||
|
e5500b6857 | ||
|
646d134f89 | ||
|
08220eed02 | ||
|
e4664abd21 | ||
|
7f0340605f | ||
|
8970b07e17 | ||
|
bfb13c68da | ||
|
1e5b04d006 | ||
|
d823c7916f | ||
|
ba4f5ecbb5 | ||
|
eb23123bba | ||
|
eacd9010e1 | ||
|
8c56410338 | ||
|
9e789e1786 | ||
|
ca19534e4e | ||
|
066fb89d6e | ||
|
b533b396e7 | ||
|
5fc86e517d | ||
|
aa8520dfc3 | ||
|
cb23999f5b | ||
|
fac74ee92e | ||
|
a0e5126d07 | ||
|
83a2b7171f | ||
|
501c7e4005 | ||
|
af3740ac49 | ||
|
bd38b3c5ef | ||
|
9d46f3c08e | ||
|
94ebcbf6d2 | ||
|
98738a16c9 | ||
|
50fec138c9 | ||
|
77997e2720 | ||
|
3421b2303b | ||
|
aa39ff475c | ||
|
163c87ca3d | ||
|
32948dbf16 | ||
|
6e7ef76f7e | ||
|
f2ff3ef168 | ||
|
e413543222 | ||
|
058356d1b8 | ||
|
3ec36e0932 | ||
|
46e078fce1 | ||
|
f88260dc3f | ||
|
289b96ac3d | ||
|
9959cb14a4 | ||
|
ccd2f5307c | ||
|
ce90a5bbca | ||
|
df4ab31040 | ||
|
c4b1237ba6 | ||
|
62f8aaffc5 | ||
|
c6aed8d847 | ||
|
6c1f728fdf | ||
|
481ebd24d7 | ||
|
f1ba9fc2e0 | ||
|
9509451b6c | ||
|
cecaf4cd16 | ||
|
f8c9fc191a | ||
|
0f9ad6bf80 | ||
|
dac089b320 | ||
|
2bfe646996 | ||
|
c89e8e95fa | ||
|
7ade15e9e3 | ||
|
8b880299b8 | ||
|
e7f83e1bfd | ||
|
52eb4c195f | ||
|
e2a0b8b564 | ||
|
f7cc43f68c | ||
|
60838ac029 | ||
|
cae494458c | ||
|
68e1259ffa | ||
|
3c12e99970 | ||
|
63962343d9 | ||
|
1e1b7540f3 | ||
|
a39e0a77a7 | ||
|
57f8d3ac17 | ||
|
dc2dbd07b3 | ||
|
c8e9b5d5c8 | ||
|
876ea65140 | ||
|
cc5fb060ed | ||
|
95c99759ff | ||
|
9626bfb32b | ||
|
dc7df99114 | ||
|
9f60354e0d | ||
|
0f7837458f | ||
|
4bce51430e | ||
|
3790a405dc | ||
|
4ea89b3a5a | ||
|
9979b22a74 | ||
|
8f30d97f86 | ||
|
87ebbf0f08 | ||
|
8bdad48823 | ||
|
64732e26e2 | ||
|
dd114eee69 | ||
|
5eb6d84572 | ||
|
c46d13731d | ||
|
00229c39e4 | ||
|
a95d6c939a | ||
|
36d794c1c4 | ||
|
84598c592c | ||
|
aee4ca2a4f | ||
|
0bda5ba4cc | ||
|
b4bc3d0eb2 | ||
|
a1b19a54d7 | ||
|
4adb408722 | ||
|
dba6e36f96 | ||
|
83b6bdbdc6 | ||
|
2468efe604 | ||
|
e6a8c0508e | ||
|
7aaf193682 | ||
|
7734c9c32f | ||
|
61c5efc74f | ||
|
2c1f16a3fb | ||
|
c72051a08e | ||
|
60bd320fbc | ||
|
600e332aae | ||
|
6a51de04da | ||
|
64c8415714 | ||
|
52f8b015b1 | ||
|
a4adb07ebf | ||
|
b69bbe897f | ||
|
0a7420c42b | ||
|
b22b5ee651 | ||
|
9b4ec74823 | ||
|
1f30d4e403 | ||
|
b563bbbd98 | ||
|
8422a5c9d8 | ||
|
bec5c2cbda | ||
|
2a5879c177 | ||
|
a7d58eed9a | ||
|
d2136f1447 | ||
|
1020a46435 | ||
|
8258b1cca1 | ||
|
afbe50c388 | ||
|
20621aa626 | ||
|
c8cad0f277 | ||
|
76b5ec3638 | ||
|
d912561bfb | ||
|
5907c94a2e | ||
|
7b9b58f1e0 | ||
|
c87bf2e4fc | ||
|
7773c4c2c9 | ||
|
b380624c9f | ||
|
caed880672 | ||
|
6cc9a5fd44 | ||
|
80a4079bbf | ||
|
cc687b140f | ||
|
29cf3a3174 | ||
|
837595de56 | ||
|
1a2da7636b | ||
|
774cfd8b65 | ||
|
9170b5f5cd | ||
|
e42dd5d4b2 | ||
|
58f077b4ae |
1
.github/workflows/docker-build.yml
vendored
1
.github/workflows/docker-build.yml
vendored
@@ -195,6 +195,7 @@ 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: |
|
||||
|
35
.github/workflows/stale.yml
vendored
Normal file
35
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
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
2
.gitignore
vendored
@@ -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
|
||||
|
35
.gitlint
Normal file
35
.gitlint
Normal file
@@ -0,0 +1,35 @@
|
||||
[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
|
@@ -33,3 +33,16 @@ repos:
|
||||
- "pandas-stubs==2.2.3.241009"
|
||||
- "numpy==2.1.3"
|
||||
pass_filenames: false
|
||||
- repo: https://github.com/jackdewinter/pymarkdown
|
||||
rev: v0.9.29
|
||||
hooks:
|
||||
- id: pymarkdown
|
||||
files: ^docs/
|
||||
exclude: ^docs/_generated
|
||||
args:
|
||||
- --config=docs/pymarkdown.json
|
||||
- scan
|
||||
- repo: https://github.com/jorisroovers/gitlint
|
||||
rev: v0.19.1
|
||||
hooks:
|
||||
- id: gitlint
|
||||
|
193
CHANGELOG.md
Normal file
193
CHANGELOG.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the akkudoktoreos project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - 2025-09-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added Changelog for 0.0.0 amd 0.1.0
|
||||
|
||||
## [0.0.0] - 2025-09-30
|
||||
|
||||
This version represents one year of development of EOS (Energy Optimization System). From this point forward, release management will be introduced.
|
||||
|
||||
### Added
|
||||
|
||||
#### Core Features
|
||||
- Energy Management System (EMS) with battery optimization
|
||||
- PV (Photovoltaic) forecast integration with multiple providers
|
||||
- Load prediction and forecasting capabilities
|
||||
- Electricity price integration
|
||||
- VRM API integration for load and PV forecasting
|
||||
- Battery State of Charge (SoC) prediction and optimization
|
||||
- Inverter class with AC/DC charging logic
|
||||
- Electric vehicle (EV) charging optimization with configurable currents
|
||||
- Home appliance scheduling optimization
|
||||
- Horizon validation for shading calculations
|
||||
|
||||
#### API & Server
|
||||
- Migration from Flask to FastAPI
|
||||
- RESTful API with comprehensive endpoints
|
||||
- EOSdash web interface for configuration and visualization
|
||||
- Docker support with multi-architecture builds
|
||||
- Web-based visualization with interactive charts
|
||||
- OpenAPI/Swagger documentation
|
||||
- Configurable server settings (port, host)
|
||||
|
||||
#### Configuration & Data Management
|
||||
- JSON-based configuration system with nested support
|
||||
- Configuration validation with Pydantic
|
||||
- Device registry for managing multiple devices
|
||||
- Persistent caching for predictions and prices
|
||||
- Manual prediction updates
|
||||
- Timezone support with automatic detection
|
||||
- Configurable VAT rates for electricity prices
|
||||
|
||||
#### Optimization
|
||||
- DEAP-based genetic algorithm optimization
|
||||
- Multi-objective optimization (cost, battery usage, self-consumption)
|
||||
- 48-hour prediction and optimization window
|
||||
- AC/DC charging decision optimization
|
||||
- Discharge hour optimization
|
||||
- Start solution enforcement
|
||||
- Fitness visualization with violin plots
|
||||
- Self-consumption probability interpolator
|
||||
|
||||
#### Testing & Quality
|
||||
- Comprehensive test suite with pytest
|
||||
- Unit tests for major components (EMS, battery, inverter, load, optimization)
|
||||
- Integration tests for server endpoints
|
||||
- Pre-commit hooks for code quality
|
||||
- Type checking with mypy
|
||||
- Code formatting with ruff and isort
|
||||
- Markdown linting
|
||||
|
||||
#### Documentation
|
||||
- Conceptual documentation
|
||||
- API documentation with Sphinx
|
||||
- ReadTheDocs integration
|
||||
- Docker setup instructions
|
||||
- Contributing guidelines
|
||||
- English README translation
|
||||
|
||||
#### Providers & Integrations
|
||||
- PVForecast.Akkudoktor provider
|
||||
- BrightSky weather provider
|
||||
- ClearOutside weather provider
|
||||
- Electricity price provider
|
||||
|
||||
### Changed
|
||||
- Python version requirement updated to 3.10+
|
||||
- Optimized Inverter class for improved SCR calculation performance
|
||||
- Improved caching mechanisms for better performance
|
||||
- Enhanced visualization with proper timestamp handling
|
||||
- Updated dependency management with automatic Dependabot updates
|
||||
- Restructured code into logical submodules
|
||||
- Package directory structure reorganization
|
||||
- Improved error handling and logging
|
||||
- Windows compatibility improvements
|
||||
|
||||
### Fixed
|
||||
- Cross-site scripting (XSS) vulnerabilities
|
||||
- ReDoS vulnerability in duration parsing
|
||||
- Timezone and daylight saving time handling
|
||||
- BrightSky provider with None humidity data
|
||||
- Negative values in load mean adjusted calculations
|
||||
- SoC calculation bugs
|
||||
- AC charge efficiency in price calculations
|
||||
- Optimization timing bugs
|
||||
- Docker BuildKit compatibility
|
||||
- Float value handling in user horizon configuration
|
||||
- Circular runtime import issues
|
||||
- Load simulation data return issues
|
||||
- Multiple optimization-related bugs
|
||||
|
||||
### Security
|
||||
- Added Bandit security checks
|
||||
- Fixed XSS vulnerabilities
|
||||
- Mitigated ReDoS attacks with input length validation
|
||||
- Improved credential management with environment variables
|
||||
|
||||
### Dependencies
|
||||
Major dependencies included in this release:
|
||||
- FastAPI 0.115.14
|
||||
- Pydantic 2.11.9
|
||||
- NumPy 2.3.3
|
||||
- Pandas 2.3.2
|
||||
- Scikit-learn 1.7.2
|
||||
- Uvicorn 0.36.0
|
||||
- Bokeh 3.8.0
|
||||
- Matplotlib 3.10.6
|
||||
- PVLib 0.13.1
|
||||
- Python-FastHTML 0.12.29
|
||||
|
||||
### Development Notes
|
||||
This version encompasses all development from the initial commit (February 16, 2024) through September 29, 2025. The project evolved from a basic energy optimization concept to a comprehensive energy management system with:
|
||||
- 698+ commits
|
||||
- Multiple contributor involvement
|
||||
- Continuous integration/deployment setup
|
||||
- Automated dependency updates
|
||||
- Comprehensive testing infrastructure
|
||||
|
||||
### Migration Notes
|
||||
As this is the initial versioned release, no migration is required. Future releases will include migration guides as needed.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: Initial development phase (v0.0.0)
|
||||
|
||||
|
||||
## v0.1.0-a0 (2025-09-30)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- This is a BREAKING CHANGE as the configuration structure changed
|
||||
once again and the server API was also enhanced and streamlined. The server API
|
||||
that is used by Andreas and Jörg in their videos has not changed
|
||||
- This is a BREAKING CHANGE as the configuration structure changed
|
||||
once again and the server API was also enhanced and streamlined. The server API
|
||||
that is used by Andreas and Jörg in their videos has not changed.
|
||||
- EOS configuration changed. V1 API changed.
|
||||
- Default IP address for EOS and EOSdash changed to 127.0.0.1
|
||||
- Azimuth configurations that followed the PVForecastAkkudoktor convention
|
||||
(north=+-180, east=-90, south=0, west=90) must be converted to the general azimuth definition:
|
||||
north=0, east=90, south=180, west=270.
|
||||
|
||||
### Feat
|
||||
|
||||
- **VRM forecast**: add load and pv forecast by VRM API (#611)
|
||||
- run pytest for PRs
|
||||
- be helpful, provide a list of valid routes when visiting /
|
||||
- add documentation, enable makefile driven usage
|
||||
- Detailliertere README
|
||||
- andere ports/bind ips erlauben
|
||||
|
||||
### Fix
|
||||
|
||||
- dependencies and optimization solution beginning
|
||||
- typos in bokeh.py
|
||||
- automatic optimization
|
||||
- handle float values in userhorizon configuration (#657)
|
||||
- **docker**: make EOSDash accessible in Docker containers (#656)
|
||||
- **ElecPriceEnergyCharts**: get history series, update docs (#606)
|
||||
- logging, prediction update, multiple bugs (#584)
|
||||
- add required fields to example optimization request (#574)
|
||||
- pvforecast fails when there is only a single plane (#569)
|
||||
- delete empty inverter from testdata optimize_input_2.json (#568)
|
||||
- azimuth setting of pvforecastakkudoktor provider (#567)
|
||||
- BrightSky with None humidity data (#555)
|
||||
- Catch optimize error and return error message. (#534)
|
||||
- Circular runtime import Closes #533 (#535)
|
||||
- **docker**: enable BuildKit to support --mount (closes #493)
|
||||
- mitigate ReDoS in to_duration via max input length check (closes #494) (#523)
|
||||
- relax stale issue/pr handling
|
||||
- remove verbose comment
|
||||
- make port configurable via env
|
||||
|
||||
### Refactor
|
||||
|
||||
- remove `README-DE.md`
|
@@ -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/main/).
|
||||
Latest development documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/).
|
||||
|
||||
## Bug Reports
|
||||
|
||||
@@ -21,8 +21,8 @@ There are just too many possibilities and the project would drown in tickets oth
|
||||
## Code Contributions
|
||||
|
||||
We welcome code contributions and bug fixes via [Pull Requests](https://github.com/Akkudoktor-EOS/EOS/pulls).
|
||||
To make collaboration easier, we require pull requests to pass code style and unit tests.
|
||||
|
||||
To make collaboration easier, we require pull requests to pass code style, unit tests, and commit
|
||||
message style checks.
|
||||
|
||||
### Setup development environment
|
||||
|
||||
@@ -60,6 +60,7 @@ To run formatting automatically before every commit:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
pre-commit install --hook-type commit-msg
|
||||
```
|
||||
|
||||
Or run them manually:
|
||||
@@ -75,3 +76,8 @@ Use `pytest` to run tests locally:
|
||||
```bash
|
||||
python -m pytest -vs --cov src --cov-report term-missing tests/
|
||||
```
|
||||
|
||||
### Commit message style
|
||||
|
||||
Our commit message checks use [`gitlint`](https://github.com/jorisroovers/gitlint). The checks
|
||||
enforce the [`Conventional Commits`](https://www.conventionalcommits.org) commit message style.
|
||||
|
17
Dockerfile
17
Dockerfile
@@ -1,3 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG PYTHON_VERSION=3.12.7
|
||||
FROM python:${PYTHON_VERSION}-slim
|
||||
|
||||
@@ -9,6 +10,9 @@ ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
|
||||
ENV EOS_OUTPUT_DIR="${EOS_DIR}/output"
|
||||
ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
|
||||
|
||||
# Overwrite when starting the container in a production environment
|
||||
ENV EOS_SERVER__EOSDASH_SESSKEY=s3cr3t
|
||||
|
||||
WORKDIR ${EOS_DIR}
|
||||
|
||||
RUN adduser --system --group --no-create-home eos \
|
||||
@@ -31,6 +35,19 @@ RUN mkdir -p src && pip install -e .
|
||||
|
||||
COPY src src
|
||||
|
||||
# Create minimal default configuration for Docker to fix EOSDash accessibility (#629)
|
||||
# This ensures EOSDash binds to 0.0.0.0 instead of 127.0.0.1 in containers
|
||||
RUN echo '{\n\
|
||||
"server": {\n\
|
||||
"host": "0.0.0.0",\n\
|
||||
"port": 8503,\n\
|
||||
"startup_eosdash": true,\n\
|
||||
"eosdash_host": "0.0.0.0",\n\
|
||||
"eosdash_port": 8504\n\
|
||||
}\n\
|
||||
}' > "${EOS_CONFIG_DIR}/EOS.config.json" \
|
||||
&& chown eos:eos "${EOS_CONFIG_DIR}/EOS.config.json"
|
||||
|
||||
USER eos
|
||||
ENTRYPOINT []
|
||||
|
||||
|
24
Makefile
24
Makefile
@@ -1,5 +1,5 @@
|
||||
# Define the targets
|
||||
.PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format mypy run run-dev
|
||||
.PHONY: help venv pip install dist test test-full docker-run docker-build docs read-docs clean format gitlint mypy run run-dev
|
||||
|
||||
# Default target
|
||||
all: help
|
||||
@@ -11,18 +11,22 @@ 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 tests with full optimization."
|
||||
@echo " test-ci - Run tests as CI does. No user config file allowed."
|
||||
@echo " dist - Create distribution (in dist/)."
|
||||
@echo " clean - Remove generated documentation, distribution and virtual environment."
|
||||
|
||||
@@ -72,6 +76,11 @@ read-docs: docs
|
||||
@echo "Read the documentation in your browser"
|
||||
.venv/bin/python -m webbrowser build/docs/html/index.html
|
||||
|
||||
# Clean Python bytecode
|
||||
clean-bytecode:
|
||||
find . -type d -name "__pycache__" -exec rm -r {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
|
||||
# Clean target to remove generated documentation and documentation artefacts
|
||||
clean-docs:
|
||||
@echo "Searching and deleting all '_autosum' directories in docs..."
|
||||
@@ -110,6 +119,11 @@ test:
|
||||
@echo "Running tests..."
|
||||
.venv/bin/pytest -vs --cov src --cov-report term-missing
|
||||
|
||||
# Target to run tests as done by CI on Github.
|
||||
test-ci:
|
||||
@echo "Running tests as CI..."
|
||||
.venv/bin/pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing
|
||||
|
||||
# Target to run all tests.
|
||||
test-full:
|
||||
@echo "Running all tests..."
|
||||
@@ -119,6 +133,10 @@ test-full:
|
||||
format:
|
||||
.venv/bin/pre-commit run --all-files
|
||||
|
||||
# Target to trigger gitlint using pre-commit for the last commit message
|
||||
gitlint:
|
||||
.venv/bin/pre-commit run gitlint --hook-stage commit-msg --commit-msg-filename .git/COMMIT_EDITMSG
|
||||
|
||||
# Target to format code.
|
||||
mypy:
|
||||
.venv/bin/mypy
|
||||
|
17
README.md
17
README.md
@@ -8,9 +8,19 @@ Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedoc
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## System requirements
|
||||
|
||||
- Python >= 3.11, < 3.13
|
||||
- Architecture: amd64, aarch64 (armv8)
|
||||
- OS: Linux, Windows, macOS
|
||||
|
||||
Note: For Python 3.13 some dependencies (e.g. [Pendulum](https://github.com/python-pendulum/Pendulum)) are not yet available on https://pypi.org and have to be manually compiled (a recent [Rust](https://www.rust-lang.org/tools/install) installation is required).
|
||||
|
||||
Other architectures (e.g. armv6, armv7) are unsupported for now, because a multitude of dependencies are not available on https://piwheels.org and have to be built manually (a recent Rust installation and [GCC](https://gcc.gnu.org/) are required, Python 3.11 is recommended).
|
||||
|
||||
## Installation
|
||||
|
||||
The project requires Python 3.10 or newer. Official docker images can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos).
|
||||
The project requires Python 3.11 or newer. Docker images (amd64/aarch64) can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos).
|
||||
|
||||
Following sections describe how to locally start the EOS server on `http://localhost:8503`.
|
||||
|
||||
@@ -30,11 +40,12 @@ Windows:
|
||||
|
||||
```cmd
|
||||
python -m venv .venv
|
||||
.venv\Scripts\Activate
|
||||
.venv\Scripts\pip install -r requirements.txt
|
||||
.venv\Scripts\pip install -e .
|
||||
```
|
||||
|
||||
Finally, start the EOS server:
|
||||
Finally, start the EOS server to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`):
|
||||
|
||||
Linux:
|
||||
|
||||
@@ -50,6 +61,8 @@ Windows:
|
||||
|
||||
### Docker
|
||||
|
||||
Start EOS with following command to access it at `http://localhost:8503` (API docs at `http://localhost:8503/docs`):
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
@@ -21,5 +21,25 @@ services:
|
||||
- EOS_ELECPRICE__PROVIDER=ElecPriceAkkudoktor
|
||||
- EOS_ELECPRICE__CHARGES_KWH=0.21
|
||||
ports:
|
||||
- "${EOS_SERVER__PORT}:${EOS_SERVER__PORT}"
|
||||
- "${EOS_SERVER__EOSDASH_PORT}:${EOS_SERVER__EOSDASH_PORT}"
|
||||
# Configure what ports to expose on host
|
||||
- "${EOS_SERVER__PORT}:8503"
|
||||
- "${EOS_SERVER__EOSDASH_PORT}:8504"
|
||||
|
||||
# Volume mount configuration (optional)
|
||||
# IMPORTANT: When mounting local directories, the default config won't be available.
|
||||
# You must create an EOS.config.json file in your local config directory with:
|
||||
# {
|
||||
# "server": {
|
||||
# "host": "0.0.0.0", # Required for Docker container accessibility
|
||||
# "port": 8503,
|
||||
# "startup_eosdash": true,
|
||||
# "eosdash_host": "0.0.0.0", # Required for Docker container accessibility
|
||||
# "eosdash_port": 8504
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Example volume mounts (uncomment to use):
|
||||
# volumes:
|
||||
# - ./config:/opt/eos/config # Mount local config directory
|
||||
# - ./cache:/opt/eos/cache # Mount local cache directory
|
||||
# - ./output:/opt/eos/output # Mount local output directory
|
||||
|
@@ -15,10 +15,6 @@ Properties:
|
||||
timezone (Optional[str]): Computed time zone string based on the specified latitude
|
||||
and longitude.
|
||||
|
||||
Validators:
|
||||
validate_latitude (float): Ensures `latitude` is within the range -90 to 90.
|
||||
validate_longitude (float): Ensures `longitude` is within the range -180 to 180.
|
||||
|
||||
:::{table} general
|
||||
:widths: 10 20 10 5 5 30
|
||||
:align: left
|
||||
@@ -127,8 +123,10 @@ Validators:
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| level | `EOS_LOGGING__LEVEL` | `Optional[str]` | `rw` | `None` | EOS default logging level. |
|
||||
| root_level | | `str` | `ro` | `N/A` | Root logger logging level. |
|
||||
| level | `EOS_LOGGING__LEVEL` | `Optional[str]` | `rw` | `None` | This is deprecated. Use console_level and file_level instead. |
|
||||
| console_level | `EOS_LOGGING__CONSOLE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level when logging to console. |
|
||||
| file_level | `EOS_LOGGING__FILE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level when logging to file. |
|
||||
| file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Computed log file path based on data output path. |
|
||||
:::
|
||||
|
||||
### Example Input
|
||||
@@ -138,7 +136,9 @@ Validators:
|
||||
|
||||
{
|
||||
"logging": {
|
||||
"level": "INFO"
|
||||
"level": null,
|
||||
"console_level": "TRACE",
|
||||
"file_level": "TRACE"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -150,8 +150,10 @@ Validators:
|
||||
|
||||
{
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"root_level": "INFO"
|
||||
"level": null,
|
||||
"console_level": "TRACE",
|
||||
"file_level": "TRACE",
|
||||
"file_path": "/home/user/.local/share/net.akkudoktoreos.net/output/eos.log"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -422,6 +424,7 @@ Validators:
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
|
||||
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `Optional[float]` | `rw` | `None` | Electricity price charges (€/kWh). |
|
||||
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `Optional[float]` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
|
||||
| provider_settings | `EOS_ELECPRICE__PROVIDER_SETTINGS` | `Optional[akkudoktoreos.prediction.elecpriceimport.ElecPriceImportCommonSettings]` | `rw` | `None` | Provider settings |
|
||||
:::
|
||||
|
||||
@@ -434,6 +437,7 @@ Validators:
|
||||
"elecprice": {
|
||||
"provider": "ElecPriceAkkudoktor",
|
||||
"charges_kwh": 0.21,
|
||||
"vat_rate": 1.19,
|
||||
"provider_settings": null
|
||||
}
|
||||
}
|
||||
@@ -475,7 +479,7 @@ Validators:
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| provider | `EOS_LOAD__PROVIDER` | `Optional[str]` | `rw` | `None` | Load provider id of provider to be used. |
|
||||
| provider_settings | `EOS_LOAD__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings, akkudoktoreos.prediction.loadimport.LoadImportCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
|
||||
| provider_settings | `EOS_LOAD__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings, akkudoktoreos.prediction.loadvrm.LoadVrmCommonSettings, akkudoktoreos.prediction.loadimport.LoadImportCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
|
||||
:::
|
||||
|
||||
### Example Input/Output
|
||||
@@ -518,6 +522,33 @@ Validators:
|
||||
}
|
||||
```
|
||||
|
||||
### Common settings for VRM API
|
||||
|
||||
:::{table} load::provider_settings
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| load_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
|
||||
| load_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
|
||||
:::
|
||||
|
||||
#### Example Input/Output
|
||||
|
||||
```{eval-rst}
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"load": {
|
||||
"provider_settings": {
|
||||
"load_vrm_token": "your-token",
|
||||
"load_vrm_idsite": 12345
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common settings for load data import from file
|
||||
|
||||
:::{table} load::provider_settings
|
||||
@@ -552,8 +583,9 @@ Validators:
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| provider | `EOS_PVFORECAST__PROVIDER` | `Optional[str]` | `rw` | `None` | PVForecast provider id of provider to be used. |
|
||||
| provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings, akkudoktoreos.prediction.pvforecastvrm.PVforecastVrmCommonSettings, NoneType]` | `rw` | `None` | Provider settings |
|
||||
| planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. |
|
||||
| provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | Provider settings |
|
||||
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set |
|
||||
| planes_peakpower | | `List[float]` | `ro` | `N/A` | Compute a list of the peak power per active planes. |
|
||||
| planes_azimuth | | `List[float]` | `ro` | `N/A` | Compute a list of the azimuths per active planes. |
|
||||
| planes_tilt | | `List[float]` | `ro` | `N/A` | Compute a list of the tilts per active planes. |
|
||||
@@ -569,10 +601,11 @@ Validators:
|
||||
{
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"provider_settings": null,
|
||||
"planes": [
|
||||
{
|
||||
"surface_tilt": 10.0,
|
||||
"surface_azimuth": 10.0,
|
||||
"surface_azimuth": 180.0,
|
||||
"userhorizon": [
|
||||
10.0,
|
||||
20.0,
|
||||
@@ -594,7 +627,7 @@ Validators:
|
||||
},
|
||||
{
|
||||
"surface_tilt": 20.0,
|
||||
"surface_azimuth": 20.0,
|
||||
"surface_azimuth": 90.0,
|
||||
"userhorizon": [
|
||||
5.0,
|
||||
15.0,
|
||||
@@ -615,7 +648,7 @@ Validators:
|
||||
"strings_per_inverter": 2
|
||||
}
|
||||
],
|
||||
"provider_settings": null
|
||||
"max_planes": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -628,10 +661,11 @@ Validators:
|
||||
{
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"provider_settings": null,
|
||||
"planes": [
|
||||
{
|
||||
"surface_tilt": 10.0,
|
||||
"surface_azimuth": 10.0,
|
||||
"surface_azimuth": 180.0,
|
||||
"userhorizon": [
|
||||
10.0,
|
||||
20.0,
|
||||
@@ -653,7 +687,7 @@ Validators:
|
||||
},
|
||||
{
|
||||
"surface_tilt": 20.0,
|
||||
"surface_azimuth": 20.0,
|
||||
"surface_azimuth": 90.0,
|
||||
"userhorizon": [
|
||||
5.0,
|
||||
15.0,
|
||||
@@ -674,14 +708,14 @@ Validators:
|
||||
"strings_per_inverter": 2
|
||||
}
|
||||
],
|
||||
"provider_settings": null,
|
||||
"max_planes": 0,
|
||||
"planes_peakpower": [
|
||||
5.0,
|
||||
3.5
|
||||
],
|
||||
"planes_azimuth": [
|
||||
10.0,
|
||||
20.0
|
||||
180.0,
|
||||
90.0
|
||||
],
|
||||
"planes_tilt": [
|
||||
10.0,
|
||||
@@ -707,6 +741,116 @@ Validators:
|
||||
}
|
||||
```
|
||||
|
||||
### PV Forecast Plane Configuration
|
||||
|
||||
:::{table} pvforecast::planes::list
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| surface_tilt | `Optional[float]` | `rw` | `30.0` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
|
||||
| surface_azimuth | `Optional[float]` | `rw` | `180.0` | Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). |
|
||||
| userhorizon | `Optional[List[float]]` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
|
||||
| peakpower | `Optional[float]` | `rw` | `None` | Nominal power of PV system in kW. |
|
||||
| pvtechchoice | `Optional[str]` | `rw` | `crystSi` | PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. |
|
||||
| mountingplace | `Optional[str]` | `rw` | `free` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
|
||||
| loss | `Optional[float]` | `rw` | `14.0` | Sum of PV system losses in percent |
|
||||
| trackingtype | `Optional[int]` | `rw` | `None` | Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. |
|
||||
| optimal_surface_tilt | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
|
||||
| optimalangles | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
|
||||
| albedo | `Optional[float]` | `rw` | `None` | Proportion of the light hitting the ground that it reflects back. |
|
||||
| module_model | `Optional[str]` | `rw` | `None` | Model of the PV modules of this plane. |
|
||||
| inverter_model | `Optional[str]` | `rw` | `None` | Model of the inverter of this plane. |
|
||||
| inverter_paco | `Optional[int]` | `rw` | `None` | AC power rating of the inverter [W]. |
|
||||
| modules_per_string | `Optional[int]` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
|
||||
| strings_per_inverter | `Optional[int]` | `rw` | `None` | Number of the strings of the inverter of this plane. |
|
||||
:::
|
||||
|
||||
#### Example Input/Output
|
||||
|
||||
```{eval-rst}
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"pvforecast": {
|
||||
"planes": [
|
||||
{
|
||||
"surface_tilt": 10.0,
|
||||
"surface_azimuth": 180.0,
|
||||
"userhorizon": [
|
||||
10.0,
|
||||
20.0,
|
||||
30.0
|
||||
],
|
||||
"peakpower": 5.0,
|
||||
"pvtechchoice": "crystSi",
|
||||
"mountingplace": "free",
|
||||
"loss": 14.0,
|
||||
"trackingtype": 0,
|
||||
"optimal_surface_tilt": false,
|
||||
"optimalangles": false,
|
||||
"albedo": null,
|
||||
"module_model": null,
|
||||
"inverter_model": null,
|
||||
"inverter_paco": 6000,
|
||||
"modules_per_string": 20,
|
||||
"strings_per_inverter": 2
|
||||
},
|
||||
{
|
||||
"surface_tilt": 20.0,
|
||||
"surface_azimuth": 90.0,
|
||||
"userhorizon": [
|
||||
5.0,
|
||||
15.0,
|
||||
25.0
|
||||
],
|
||||
"peakpower": 3.5,
|
||||
"pvtechchoice": "crystSi",
|
||||
"mountingplace": "free",
|
||||
"loss": 14.0,
|
||||
"trackingtype": 1,
|
||||
"optimal_surface_tilt": false,
|
||||
"optimalangles": false,
|
||||
"albedo": null,
|
||||
"module_model": null,
|
||||
"inverter_model": null,
|
||||
"inverter_paco": 4000,
|
||||
"modules_per_string": 20,
|
||||
"strings_per_inverter": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common settings for VRM API
|
||||
|
||||
:::{table} pvforecast::provider_settings
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| pvforecast_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
|
||||
| pvforecast_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
|
||||
:::
|
||||
|
||||
#### Example Input/Output
|
||||
|
||||
```{eval-rst}
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"pvforecast": {
|
||||
"provider_settings": {
|
||||
"pvforecast_vrm_token": "your-token",
|
||||
"pvforecast_vrm_idsite": 12345
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common settings for pvforecast data import from file or JSON string
|
||||
|
||||
:::{table} pvforecast::provider_settings
|
||||
@@ -734,89 +878,6 @@ Validators:
|
||||
}
|
||||
```
|
||||
|
||||
### PV Forecast Plane Configuration
|
||||
|
||||
:::{table} pvforecast::planes::list
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| surface_tilt | `Optional[float]` | `rw` | `None` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
|
||||
| surface_azimuth | `Optional[float]` | `rw` | `None` | Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). |
|
||||
| userhorizon | `Optional[List[float]]` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
|
||||
| peakpower | `Optional[float]` | `rw` | `None` | Nominal power of PV system in kW. |
|
||||
| pvtechchoice | `Optional[str]` | `rw` | `crystSi` | PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. |
|
||||
| mountingplace | `Optional[str]` | `rw` | `free` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
|
||||
| loss | `Optional[float]` | `rw` | `14.0` | Sum of PV system losses in percent |
|
||||
| trackingtype | `Optional[int]` | `rw` | `None` | Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. |
|
||||
| optimal_surface_tilt | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
|
||||
| optimalangles | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
|
||||
| albedo | `Optional[float]` | `rw` | `None` | Proportion of the light hitting the ground that it reflects back. |
|
||||
| module_model | `Optional[str]` | `rw` | `None` | Model of the PV modules of this plane. |
|
||||
| inverter_model | `Optional[str]` | `rw` | `None` | Model of the inverter of this plane. |
|
||||
| inverter_paco | `Optional[int]` | `rw` | `None` | AC power rating of the inverter. [W] |
|
||||
| modules_per_string | `Optional[int]` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
|
||||
| strings_per_inverter | `Optional[int]` | `rw` | `None` | Number of the strings of the inverter of this plane. |
|
||||
:::
|
||||
|
||||
#### Example Input/Output
|
||||
|
||||
```{eval-rst}
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"pvforecast": {
|
||||
"planes": [
|
||||
{
|
||||
"surface_tilt": 10.0,
|
||||
"surface_azimuth": 10.0,
|
||||
"userhorizon": [
|
||||
10.0,
|
||||
20.0,
|
||||
30.0
|
||||
],
|
||||
"peakpower": 5.0,
|
||||
"pvtechchoice": "crystSi",
|
||||
"mountingplace": "free",
|
||||
"loss": 14.0,
|
||||
"trackingtype": 0,
|
||||
"optimal_surface_tilt": false,
|
||||
"optimalangles": false,
|
||||
"albedo": null,
|
||||
"module_model": null,
|
||||
"inverter_model": null,
|
||||
"inverter_paco": 6000,
|
||||
"modules_per_string": 20,
|
||||
"strings_per_inverter": 2
|
||||
},
|
||||
{
|
||||
"surface_tilt": 20.0,
|
||||
"surface_azimuth": 20.0,
|
||||
"userhorizon": [
|
||||
5.0,
|
||||
15.0,
|
||||
25.0
|
||||
],
|
||||
"peakpower": 3.5,
|
||||
"pvtechchoice": "crystSi",
|
||||
"mountingplace": "free",
|
||||
"loss": 14.0,
|
||||
"trackingtype": 1,
|
||||
"optimal_surface_tilt": false,
|
||||
"optimalangles": false,
|
||||
"albedo": null,
|
||||
"module_model": null,
|
||||
"inverter_model": null,
|
||||
"inverter_paco": 4000,
|
||||
"modules_per_string": 20,
|
||||
"strings_per_inverter": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Weather Forecast Configuration
|
||||
|
||||
:::{table} weather
|
||||
@@ -877,11 +938,11 @@ Validators:
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| host | `EOS_SERVER__HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `0.0.0.0` | EOS server IP address. |
|
||||
| host | `EOS_SERVER__HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `127.0.0.1` | EOS server IP address. |
|
||||
| port | `EOS_SERVER__PORT` | `Optional[int]` | `rw` | `8503` | EOS server IP port number. |
|
||||
| verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output |
|
||||
| startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `Optional[bool]` | `rw` | `True` | EOS server to start EOSdash server. |
|
||||
| eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `0.0.0.0` | EOSdash server IP address. |
|
||||
| eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `127.0.0.1` | EOSdash server IP address. |
|
||||
| eosdash_port | `EOS_SERVER__EOSDASH_PORT` | `Optional[int]` | `rw` | `8504` | EOSdash server IP port number. |
|
||||
:::
|
||||
|
||||
@@ -892,11 +953,11 @@ Validators:
|
||||
|
||||
{
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8503,
|
||||
"verbose": false,
|
||||
"startup_eosdash": true,
|
||||
"eosdash_host": "0.0.0.0",
|
||||
"eosdash_host": "127.0.0.1",
|
||||
"eosdash_port": 8504
|
||||
}
|
||||
}
|
||||
@@ -943,7 +1004,9 @@ Validators:
|
||||
"interval": 300.0
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO"
|
||||
"level": null,
|
||||
"console_level": "TRACE",
|
||||
"file_level": "TRACE"
|
||||
},
|
||||
"devices": {
|
||||
"batteries": [
|
||||
@@ -989,6 +1052,7 @@ Validators:
|
||||
"elecprice": {
|
||||
"provider": "ElecPriceAkkudoktor",
|
||||
"charges_kwh": 0.21,
|
||||
"vat_rate": 1.19,
|
||||
"provider_settings": null
|
||||
},
|
||||
"load": {
|
||||
@@ -997,10 +1061,11 @@ Validators:
|
||||
},
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastAkkudoktor",
|
||||
"provider_settings": null,
|
||||
"planes": [
|
||||
{
|
||||
"surface_tilt": 10.0,
|
||||
"surface_azimuth": 10.0,
|
||||
"surface_azimuth": 180.0,
|
||||
"userhorizon": [
|
||||
10.0,
|
||||
20.0,
|
||||
@@ -1022,7 +1087,7 @@ Validators:
|
||||
},
|
||||
{
|
||||
"surface_tilt": 20.0,
|
||||
"surface_azimuth": 20.0,
|
||||
"surface_azimuth": 90.0,
|
||||
"userhorizon": [
|
||||
5.0,
|
||||
15.0,
|
||||
@@ -1043,18 +1108,18 @@ Validators:
|
||||
"strings_per_inverter": 2
|
||||
}
|
||||
],
|
||||
"provider_settings": null
|
||||
"max_planes": 0
|
||||
},
|
||||
"weather": {
|
||||
"provider": "WeatherImport",
|
||||
"provider_settings": null
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8503,
|
||||
"verbose": false,
|
||||
"startup_eosdash": true,
|
||||
"eosdash_host": "0.0.0.0",
|
||||
"eosdash_host": "127.0.0.1",
|
||||
"eosdash_port": 8504
|
||||
},
|
||||
"utils": {}
|
||||
|
@@ -430,7 +430,13 @@ Returns:
|
||||
**Request Body**:
|
||||
|
||||
- `application/json`: {
|
||||
"description": "The value to assign to the specified configuration path.",
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "The value to assign to the specified configuration path (can be None).",
|
||||
"title": "Value"
|
||||
}
|
||||
|
||||
@@ -458,6 +464,55 @@ Health check endpoint to verify that the EOS server is alive.
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/logging/log
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_logging_get_log_v1_logging_log_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_logging_get_log_v1_logging_log_get)
|
||||
|
||||
Fastapi Logging Get Log
|
||||
|
||||
```
|
||||
Get structured log entries from the EOS log file.
|
||||
|
||||
Filters and returns log entries based on the specified query parameters. The log
|
||||
file is expected to contain newline-delimited JSON entries.
|
||||
|
||||
Args:
|
||||
limit (int): Maximum number of entries to return.
|
||||
level (Optional[str]): Filter logs by severity level (e.g., DEBUG, INFO).
|
||||
contains (Optional[str]): Return only logs that include this string in the message.
|
||||
regex (Optional[str]): Return logs that match this regular expression in the message.
|
||||
from_time (Optional[str]): ISO 8601 timestamp to filter logs not older than this.
|
||||
to_time (Optional[str]): ISO 8601 timestamp to filter logs not newer than this.
|
||||
tail (bool): If True, fetch the most recent log entries (like `tail`).
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON list of log entries.
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `limit` (query, optional): Maximum number of log entries to return.
|
||||
|
||||
- `level` (query, optional): Filter by log level (e.g., INFO, ERROR).
|
||||
|
||||
- `contains` (query, optional): Filter logs containing this substring.
|
||||
|
||||
- `regex` (query, optional): Filter logs by matching regex in message.
|
||||
|
||||
- `from_time` (query, optional): Start time (ISO format) for filtering logs.
|
||||
|
||||
- `to_time` (query, optional): End time (ISO format) for filtering logs.
|
||||
|
||||
- `tail` (query, optional): If True, returns the most recent lines (tail mode).
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## PUT /v1/measurement/data
|
||||
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put)
|
||||
@@ -743,7 +798,8 @@ Args:
|
||||
"$ref": "#/components/schemas/PydanticDateTimeData"
|
||||
},
|
||||
{
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
|
BIN
docs/_static/introduction/integration.png
vendored
Executable file
BIN
docs/_static/introduction/integration.png
vendored
Executable file
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
BIN
docs/_static/introduction/introduction.png
vendored
Executable file
BIN
docs/_static/introduction/introduction.png
vendored
Executable file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
BIN
docs/_static/introduction/overview.png
vendored
Executable file
BIN
docs/_static/introduction/overview.png
vendored
Executable file
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
1
docs/_static/optimization_timeframes-excalidraw.json
vendored
Normal file
1
docs/_static/optimization_timeframes-excalidraw.json
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/_static/optimization_timeframes.png
vendored
Normal file
BIN
docs/_static/optimization_timeframes.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 664 KiB |
@@ -1,9 +0,0 @@
|
||||
% 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.
|
@@ -20,17 +20,22 @@ 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. Sub-hour energy management is left
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -38,7 +43,8 @@ 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
|
||||
|
||||
|
@@ -31,10 +31,10 @@ Use endpoint `POST /v1/config/reset` to reset the configuration to the values in
|
||||
|
||||
The configuration sources and their priorities are as follows:
|
||||
|
||||
1. **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**
|
||||
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`
|
||||
|
||||
### Runtime Config Updates
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
% SPDX-License-Identifier: Apache-2.0
|
||||
(integration-page)=
|
||||
|
||||
# Integration
|
||||
|
||||
@@ -17,18 +18,24 @@ APIs, and online services in creative and practical ways.
|
||||
|
||||
Andreas Schmitz uses [Node-RED](https://nodered.org/) as part of his home automation setup.
|
||||
|
||||
### Resources
|
||||
### Node-Red Resources
|
||||
|
||||
- [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`.
|
||||
- [Installation Guide (German)](https://www.youtube.com/playlist?list=PL8_vk9A-s7zLD865Oou6y3EeQLlNtu-Hn)
|
||||
\— A detailed guide on integrating 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.
|
||||
|
||||
### Resources
|
||||
(duetting-solution)=
|
||||
|
||||
### Home Assistant Resources
|
||||
|
||||
- Duetting's [EOS Home Assistant Addon](https://github.com/Duetting/ha_eos_addon) — Additional
|
||||
details can be found in this
|
||||
[discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).
|
||||
details can be found in this [discussion thread](https://github.com/Akkudoktor-EOS/EOS/discussions/294).
|
||||
|
||||
## EOS Connect
|
||||
|
||||
[EOS connect](https://github.com/ohAnd/EOS_connect) uses `EOS` for energy management and optimization,
|
||||
and connects to smart home platforms to monitor, forecast, and control energy flows.
|
||||
|
180
docs/akkudoktoreos/introduction.md
Normal file
180
docs/akkudoktoreos/introduction.md
Normal file
@@ -0,0 +1,180 @@
|
||||
% 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
|
||||
|
||||

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

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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/)
|
75
docs/akkudoktoreos/logging.md
Normal file
75
docs/akkudoktoreos/logging.md
Normal file
@@ -0,0 +1,75 @@
|
||||
% SPDX-License-Identifier: Apache-2.0
|
||||
(logging-page)=
|
||||
|
||||
# Logging
|
||||
|
||||
EOS automatically records important events and messages to help you understand what’s 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.
|
@@ -5,9 +5,9 @@
|
||||
Measurements are utilized to refine predictions using real data from your system, thereby enhancing
|
||||
accuracy.
|
||||
|
||||
- **Household Load Measurement**
|
||||
- **Grid Export Measurement**
|
||||
- **Grid Import Measurement**
|
||||
- Household Load Measurement
|
||||
- Grid Export Measurement
|
||||
- Grid Import Measurement
|
||||
|
||||
## Storing Measurements
|
||||
|
||||
|
@@ -2,7 +2,207 @@
|
||||
|
||||
# Optimization
|
||||
|
||||
:::{admonition} Todo
|
||||
:class: note
|
||||
Describe optimization.
|
||||
:::
|
||||
## Introduction
|
||||
|
||||
The `POST /optimize` API endpoint optimizes your energy management system based on various inputs
|
||||
including electricity prices, battery storage capacity, PV forecast, and temperature data.
|
||||
|
||||
## Input Payload
|
||||
|
||||
### Sample Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ems": {
|
||||
"preis_euro_pro_wh_akku": 0.0007,
|
||||
"einspeiseverguetung_euro_pro_wh": 0.00007,
|
||||
"gesamtlast": [500, 500, ..., 500, 500],
|
||||
"pv_prognose_wh": [300, 0, 0, ..., 2160, 1840],
|
||||
"strompreis_euro_pro_wh": [0.0003784, 0.0003868, ..., 0.00034102, 0.00033709]
|
||||
},
|
||||
"pv_akku": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 12000,
|
||||
"charging_efficiency": 0.92,
|
||||
"discharging_efficiency": 0.92,
|
||||
"max_charge_power_w": 5700,
|
||||
"initial_soc_percentage": 66,
|
||||
"min_soc_percentage": 5,
|
||||
"max_soc_percentage": 100
|
||||
},
|
||||
"inverter": {
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 15500
|
||||
"battery_id": "battery1",
|
||||
},
|
||||
"eauto": {
|
||||
"device_id": "auto1",
|
||||
"capacity_wh": 64000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
"max_charge_power_w": 11040,
|
||||
"initial_soc_percentage": 98,
|
||||
"min_soc_percentage": 60,
|
||||
"max_soc_percentage": 100
|
||||
},
|
||||
"temperature_forecast": [18.3, 18, ..., 20.16, 19.84],
|
||||
"start_solution": null
|
||||
}
|
||||
```
|
||||
|
||||
## Input Parameters
|
||||
|
||||
### Energy Management System (EMS)
|
||||
|
||||
#### Battery Cost (`preis_euro_pro_wh_akku`)
|
||||
|
||||
- Unit: €/Wh
|
||||
- Purpose: Represents the residual value of energy stored in the battery
|
||||
- Impact: Lower values encourage battery depletion, higher values preserve charge at the end of the simulation.
|
||||
|
||||
#### Feed-in Tariff (`einspeiseverguetung_euro_pro_wh`)
|
||||
|
||||
- Unit: €/Wh
|
||||
- Purpose: Compensation received for feeding excess energy back to the grid
|
||||
|
||||
#### Total Load Forecast (`gesamtlast`)
|
||||
|
||||
- Unit: W
|
||||
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
|
||||
- Format: Array of hourly values
|
||||
- Note: Exclude optimizable loads (EV charging, battery charging, etc.)
|
||||
|
||||
##### Data Sources
|
||||
|
||||
1. Standard Load Profile: `GET /v1/prediction/list?key=load_mean` for a standard load profile based
|
||||
on your yearly consumption.
|
||||
2. Adjusted Load Profile: `GET /v1/prediction/list?key=load_mean_adjusted` for a combination of a
|
||||
standard load profile based on your yearly consumption incl. data from last 48h.
|
||||
|
||||
#### PV Generation Forecast (`pv_prognose_wh`)
|
||||
|
||||
- Unit: W
|
||||
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
|
||||
- Format: Array of hourly values
|
||||
- Data Source: `GET /v1/prediction/series?key=pvforecast_ac_power`
|
||||
|
||||
#### Electricity Price Forecast (`strompreis_euro_pro_wh`)
|
||||
|
||||
- Unit: €/Wh
|
||||
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
|
||||
- Format: Array of hourly values
|
||||
- Data Source: `GET /v1/prediction/list?key=elecprice_marketprice_wh`
|
||||
|
||||
Verify prices against your local tariffs.
|
||||
|
||||
### Battery Storage System
|
||||
|
||||
#### Configuration
|
||||
|
||||
- `device_id`: ID of battery
|
||||
- `capacity_wh`: Total battery capacity in Wh
|
||||
- `charging_efficiency`: Charging efficiency (0-1)
|
||||
- `discharging_efficiency`: Discharging efficiency (0-1)
|
||||
- `max_charge_power_w`: Maximum charging power in W
|
||||
|
||||
#### State of Charge (SoC)
|
||||
|
||||
- `initial_soc_percentage`: Current battery level (%)
|
||||
- `min_soc_percentage`: Minimum allowed SoC (%)
|
||||
- `max_soc_percentage`: Maximum allowed SoC (%)
|
||||
|
||||
### Inverter
|
||||
|
||||
- `device_id`: ID of inverter
|
||||
- `max_power_wh`: Maximum inverter power in Wh
|
||||
- `battery_id`: ID of battery
|
||||
|
||||
### Electric Vehicle (EV)
|
||||
|
||||
- `device_id`: ID of electric vehicle
|
||||
- `capacity_wh`: Battery capacity in Wh
|
||||
- `charging_efficiency`: Charging efficiency (0-1)
|
||||
- `discharging_efficiency`: Discharging efficiency (0-1)
|
||||
- `max_charge_power_w`: Maximum charging power in W
|
||||
- `initial_soc_percentage`: Current charge level (%)
|
||||
- `min_soc_percentage`: Minimum allowed SoC (%)
|
||||
- `max_soc_percentage`: Maximum allowed SoC (%)
|
||||
|
||||
### Temperature Forecast
|
||||
|
||||
- Unit: °C
|
||||
- Time Range: 48 hours (00:00 today to 23:00 tomorrow)
|
||||
- Format: Array of hourly values
|
||||
- Data Source: `GET /v1/prediction/list?key=weather_temp_air`
|
||||
|
||||
## Output Format
|
||||
|
||||
### Sample Response
|
||||
|
||||
```json
|
||||
{
|
||||
"ac_charge": [0.625, 0, ..., 0.75, 0],
|
||||
"dc_charge": [1, 1, ..., 1, 1],
|
||||
"discharge_allowed": [0, 0, 1, ..., 0, 0],
|
||||
"eautocharge_hours_float": [0.625, 0, ..., 0.75, 0],
|
||||
"result": {
|
||||
"Last_Wh_pro_Stunde": [...],
|
||||
"EAuto_SoC_pro_Stunde": [...],
|
||||
"Einnahmen_Euro_pro_Stunde": [...],
|
||||
"Gesamt_Verluste": 1514.96,
|
||||
"Gesamtbilanz_Euro": 2.51,
|
||||
"Gesamteinnahmen_Euro": 2.88,
|
||||
"Gesamtkosten_Euro": 5.39,
|
||||
"akku_soc_pro_stunde": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Output Parameters
|
||||
|
||||
#### Battery Control
|
||||
|
||||
- `ac_charge`: Grid charging schedule (0-1)
|
||||
- `dc_charge`: DC charging schedule (0-1)
|
||||
- `discharge_allowed`: Discharge permission (0 or 1)
|
||||
|
||||
0 (no charge)
|
||||
1 (charge with full load)
|
||||
|
||||
`ac_charge` multiplied by the maximum charge power of the battery results in the planned charging power.
|
||||
|
||||
#### EV Charging
|
||||
|
||||
- `eautocharge_hours_float`: EV charging schedule (0-1)
|
||||
|
||||
#### Results
|
||||
|
||||
The `result` object contains detailed information about the optimization outcome.
|
||||
The length of the array is between 25 and 48 and starts at the current hour and ends at 23:00 tomorrow.
|
||||
|
||||
- `Last_Wh_pro_Stunde`: Array of hourly load values in Wh
|
||||
- Shows the total energy consumption per hour
|
||||
- Includes household load, battery charging/discharging, and EV charging
|
||||
|
||||
- `EAuto_SoC_pro_Stunde`: Array of hourly EV state of charge values (%)
|
||||
- Shows the projected EV battery level throughout the optimization period
|
||||
|
||||
- `Einnahmen_Euro_pro_Stunde`: Array of hourly revenue values in Euro
|
||||
|
||||
- `Gesamt_Verluste`: Total energy losses in Wh
|
||||
|
||||
- `Gesamtbilanz_Euro`: Overall financial balance in Euro
|
||||
|
||||
- `Gesamteinnahmen_Euro`: Total revenue in Euro
|
||||
|
||||
- `Gesamtkosten_Euro`: Total costs in Euro
|
||||
|
||||
- `akku_soc_pro_stunde`: Array of hourly battery state of charge values (%)
|
||||
|
||||
## Timeframe overview
|
||||
|
||||
```{figure} ../_static/optimization_timeframes.png
|
||||
:alt: Timeframe Overview
|
||||
|
||||
Timeframe Overview
|
||||
```
|
||||
|
@@ -1,14 +1,15 @@
|
||||
% SPDX-License-Identifier: Apache-2.0
|
||||
(prediction-page)=
|
||||
|
||||
# Predictions
|
||||
|
||||
Predictions, along with simulations and measurements, form the foundation upon which energy
|
||||
optimization is executed. In EOS, a standard set of predictions is managed, including:
|
||||
|
||||
- **Household Load Prediction**
|
||||
- **Electricity Price Prediction**
|
||||
- **PV Power Prediction**
|
||||
- **Weather Prediction**
|
||||
- Household Load Prediction
|
||||
- Electricity Price Prediction
|
||||
- PV Power Prediction
|
||||
- Weather Prediction
|
||||
|
||||
## Storing Predictions
|
||||
|
||||
@@ -60,13 +61,15 @@ 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
|
||||
|
||||
@@ -116,9 +119,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).
|
||||
- `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.
|
||||
|
||||
@@ -130,6 +135,24 @@ 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
|
||||
@@ -162,6 +185,7 @@ Configuration options:
|
||||
- `provider`: Load provider id of provider to be used.
|
||||
|
||||
- `LoadAkkudoktor`: Retrieves from local database.
|
||||
- `LoadVrm`: Retrieves data from the VRM API by Victron Energy.
|
||||
- `LoadImport`: Imports from a file or JSON string.
|
||||
|
||||
- `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh).
|
||||
@@ -174,6 +198,27 @@ The `LoadAkkudoktor` provider retrieves generic load data from a local database
|
||||
align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration
|
||||
option.
|
||||
|
||||
### LoadVrm Provider
|
||||
|
||||
The `LoadVrm` provider retrieves load forecast data from the VRM API by Victron Energy.
|
||||
To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal.
|
||||
To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences.
|
||||
This token must be stored in the EOS configuration along with the VRM-Installations-ID.
|
||||
|
||||
```python
|
||||
{
|
||||
"load": {
|
||||
"provider": "LoadVrm",
|
||||
"provider_settings": {
|
||||
"load_vrm_token": "dummy-token",
|
||||
"load_vrm_idsite": 12345
|
||||
}
|
||||
```
|
||||
|
||||
The prediction keys for the load forecast data are:
|
||||
|
||||
- `load_mean`: Predicted load mean value (W).
|
||||
|
||||
### LoadImport Provider
|
||||
|
||||
The `LoadImport` provider is designed to import load forecast data from a file or a JSON
|
||||
@@ -212,16 +257,25 @@ 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.
|
||||
@@ -233,39 +287,73 @@ 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.
|
||||
|
||||
------
|
||||
---
|
||||
|
||||
Some of the planes configuration options directly follow the [PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en) nomenclature.
|
||||
|
||||
Detailed definitions taken from **PVGIS**:
|
||||
Detailed definitions taken from
|
||||
[PVGIS](https://joint-research-centre.ec.europa.eu/photovoltaic-geographical-information-system-pvgis/getting-started-pvgis/pvgis-user-manual_en).
|
||||
|
||||
- `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`
|
||||
|
||||
@@ -277,9 +365,10 @@ 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 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.
|
||||
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.
|
||||
|
||||
Detailed definitions from **PVLib** for PVGIS data.
|
||||
|
||||
@@ -292,7 +381,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
|
||||
|
||||
@@ -307,7 +396,8 @@ 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.
|
||||
@@ -328,34 +418,56 @@ 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
|
||||
@@ -364,8 +476,8 @@ becomes available.
|
||||
|
||||
The prediction keys for the PV forecast data are:
|
||||
|
||||
- `pvforecast_ac_power`: Total DC power (W).
|
||||
- `pvforecast_dc_power`: Total AC power (W).
|
||||
- `pvforecast_ac_power`: Total AC power (W).
|
||||
- `pvforecast_dc_power`: Total DC 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
|
||||
@@ -407,8 +519,8 @@ Configuration options:
|
||||
|
||||
- `provider`: Load provider id of provider to be used.
|
||||
|
||||
- `BrightSky`: Retrieves from https://api.brightsky.dev.
|
||||
- `ClearOutside`: Retrieves from https://clearoutside.com/forecast.
|
||||
- `BrightSky`: Retrieves from [BrightSky](https://api.brightsky.dev).
|
||||
- `ClearOutside`: Retrieves from [ClearOutside](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.
|
||||
|
@@ -75,37 +75,53 @@ This project uses the `EOS.config.json` file to manage configuration settings.
|
||||
|
||||
### Default Configuration
|
||||
|
||||
A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values.
|
||||
A default configuration file `default.config.json` is provided. This file contains all the necessary
|
||||
configuration keys with their default values.
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`.
|
||||
|
||||
- If the directory specified by `EOS_DIR` contains an existing `EOS.config.json` file, the application will use this configuration file.
|
||||
- If the `EOS.config.json` file does not exist in the specified directory, the `default.config.json` file will be copied to the directory as `EOS.config.json`.
|
||||
- If the directory specified by `EOS_DIR` contains an existing `EOS.config.json` file, the
|
||||
application will use this configuration file.
|
||||
- If the `EOS.config.json` file does not exist in the specified directory, the `default.config.json`
|
||||
file will be copied to the directory as `EOS.config.json`.
|
||||
|
||||
### Configuration Updates
|
||||
|
||||
If the configuration keys in the `EOS.config.json` file are missing or different from those in `default.config.json`, they will be automatically updated to match the default settings, ensuring that all required keys are present.
|
||||
If the configuration keys in the `EOS.config.json` file are missing or different from those in
|
||||
`default.config.json`, they will be automatically updated to match the default settings, ensuring
|
||||
that all required keys are present.
|
||||
|
||||
## Classes and Functionalities
|
||||
|
||||
This project uses various classes to simulate and optimize the components of an energy system. Each class represents a specific aspect of the system, as described below:
|
||||
This project uses various classes to simulate and optimize the components of an energy system. Each
|
||||
class represents a specific aspect of the system, as described below:
|
||||
|
||||
- `Battery`: Simulates a battery storage system, including capacity, state of charge, and now charge and discharge losses.
|
||||
- `Battery`: Simulates a battery storage system, including capacity, state of charge, and now
|
||||
charge and discharge losses.
|
||||
|
||||
- `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and historical generation data.
|
||||
- `PVForecast`: Provides forecast data for photovoltaic generation, based on weather data and
|
||||
historical generation data.
|
||||
|
||||
- `Load`: Models the load requirements of a household or business, enabling the prediction of future energy demand.
|
||||
- `Load`: Models the load requirements of a household or business, enabling the prediction of future
|
||||
energy demand.
|
||||
|
||||
- `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various operating conditions.
|
||||
- `Heatpump`: Simulates a heat pump, including its energy consumption and efficiency under various
|
||||
operating conditions.
|
||||
|
||||
- `Strompreis`: Provides information on electricity prices, enabling optimization of energy consumption and generation based on tariff information.
|
||||
- `Strompreis`: Provides information on electricity prices, enabling optimization of energy
|
||||
consumption and generation based on tariff information.
|
||||
|
||||
- `EMS`: The Energy Management System (EMS) coordinates the interaction between the various components, performs optimization, and simulates the operation of the entire energy system.
|
||||
- `EMS`: The Energy Management System (EMS) coordinates the interaction between the various
|
||||
components, performs optimization, and simulates the operation of the entire energy system.
|
||||
|
||||
These classes work together to enable a detailed simulation and optimization of the energy system. For each class, specific parameters and settings can be adjusted to test different scenarios and strategies.
|
||||
These classes work together to enable a detailed simulation and optimization of the energy system.
|
||||
For each class, specific parameters and settings can be adjusted to test different scenarios and
|
||||
strategies.
|
||||
|
||||
### Customization and Extension
|
||||
|
||||
Each class is designed to be easily customized and extended to integrate additional functions or improvements. For example, new methods can be added for more accurate modeling of PV system or battery behavior. Developers are invited to modify and extend the system according to their needs.
|
||||
Each class is designed to be easily customized and extended to integrate additional functions or
|
||||
improvements. For example, new methods can be added for more accurate modeling of PV system or
|
||||
battery behavior. Developers are invited to modify and extend the system according to their needs.
|
||||
|
@@ -8,23 +8,45 @@
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
:caption: 'Contents:'
|
||||
:caption: Overview
|
||||
|
||||
akkudoktoreos/introduction.md
|
||||
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
:caption: Tutorials
|
||||
|
||||
welcome.md
|
||||
akkudoktoreos/about.md
|
||||
develop/getting_started.md
|
||||
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
:caption: How-To Guides
|
||||
|
||||
develop/CONTRIBUTING.md
|
||||
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
:caption: Reference
|
||||
|
||||
akkudoktoreos/architecture.md
|
||||
akkudoktoreos/configuration.md
|
||||
akkudoktoreos/optimization.md
|
||||
akkudoktoreos/prediction.md
|
||||
akkudoktoreos/measurement.md
|
||||
akkudoktoreos/integration.md
|
||||
akkudoktoreos/logging.md
|
||||
akkudoktoreos/serverapi.md
|
||||
akkudoktoreos/api.rst
|
||||
|
||||
```
|
||||
|
||||
# Indices and tables
|
||||
## Indices and tables
|
||||
|
||||
- {ref}`genindex`
|
||||
- {ref}`modindex`
|
||||
|
20
docs/pymarkdown.json
Normal file
20
docs/pymarkdown.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"plugins": {
|
||||
"md007": {
|
||||
"enabled": true,
|
||||
"code_block_line_length" : 160
|
||||
},
|
||||
"md013": {
|
||||
"enabled": true,
|
||||
"line_length" : 120
|
||||
},
|
||||
"md041": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"front-matter" : {
|
||||
"enabled" : true
|
||||
}
|
||||
}
|
||||
}
|
@@ -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 will someday land on
|
||||
[Read the Docs](https://akkudoktoreos.readthedocs.io/en/latest/index.html).
|
||||
[Sphinx](https://www.sphinx-doc.org) and is available on
|
||||
[Read the Docs](https://akkudoktor-eos.readthedocs.io/en/latest/).
|
||||
|
||||
You can contribute to EOS's documentation by opening
|
||||
[GitHub issues](https://github.com/Akkudoktor-EOS/EOS/issues)
|
||||
|
9458
openapi.json
9458
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ authors = [
|
||||
description = "This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period."
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Programming Language :: Python :: 3",
|
||||
@@ -43,12 +43,18 @@ 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
|
||||
|
@@ -1,13 +1,15 @@
|
||||
-r requirements.txt
|
||||
gitpython==3.1.44
|
||||
myst-parser==4.0.0
|
||||
sphinx==8.1.3
|
||||
gitlint==0.19.1
|
||||
GitPython==3.1.45
|
||||
myst-parser==4.0.1
|
||||
sphinx==8.2.3
|
||||
sphinx_rtd_theme==3.0.2
|
||||
sphinx-tabs==3.4.7
|
||||
pytest==8.3.4
|
||||
pytest-cov==6.0.0
|
||||
pymarkdownlnt==0.9.32
|
||||
pytest==8.4.2
|
||||
pytest-cov==7.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
|
||||
mypy==1.18.2
|
||||
types-requests==2.32.4.20250913
|
||||
pandas-stubs==2.3.2.250827
|
||||
|
@@ -1,24 +1,25 @@
|
||||
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
|
||||
cachebox==5.0.2
|
||||
numpy==2.3.3
|
||||
numpydantic==1.6.11
|
||||
matplotlib==3.10.6
|
||||
fastapi[standard]==0.115.14
|
||||
python-fasthtml==0.12.29
|
||||
MonsterUI==1.0.29
|
||||
markdown-it-py==3.0.0
|
||||
mdit-py-plugins==0.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
|
||||
mdit-py-plugins==0.5.0
|
||||
bokeh==3.8.0
|
||||
uvicorn==0.36.0
|
||||
scikit-learn==1.7.2
|
||||
timezonefinder==7.0.2
|
||||
deap==1.4.3
|
||||
requests==2.32.5
|
||||
pandas==2.3.2
|
||||
pendulum==3.1.0
|
||||
platformdirs==4.4.0
|
||||
psutil==7.1.0
|
||||
pvlib==0.13.1
|
||||
pydantic==2.11.9
|
||||
statsmodels==0.14.5
|
||||
pydantic-settings==2.11.0
|
||||
linkify-it-py==2.0.3
|
||||
loguru==0.7.3
|
||||
|
@@ -4,22 +4,20 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS, GeneralSettings, get_config
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
documented_types: set[PydanticBaseModel] = set()
|
||||
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
|
||||
|
||||
@@ -145,6 +143,7 @@ def generate_config_table_md(
|
||||
field_type = field_info.annotation if regular_field else field_info.return_type
|
||||
default_value = get_default_value(field_info, regular_field)
|
||||
description = field_info.description if field_info.description else "-"
|
||||
deprecated = field_info.deprecated if field_info.deprecated else None
|
||||
read_only = "rw" if regular_field else "ro"
|
||||
type_name = get_type_name(field_type)
|
||||
|
||||
@@ -154,6 +153,11 @@ def generate_config_table_md(
|
||||
env_entry = f"| `{prefix}{config_name}` "
|
||||
else:
|
||||
env_entry = "| "
|
||||
if deprecated:
|
||||
if isinstance(deprecated, bool):
|
||||
description = "Deprecated!"
|
||||
else:
|
||||
description = deprecated
|
||||
table += f"| {field_name} {env_entry}| `{type_name}` | `{read_only}` | `{default_value}` | {description} |\n"
|
||||
|
||||
inner_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
|
||||
@@ -259,7 +263,7 @@ def generate_config_md(config_eos: ConfigEOS) -> str:
|
||||
markdown = "# Configuration Table\n\n"
|
||||
|
||||
# Generate tables for each top level config
|
||||
for field_name, field_info in config_eos.model_fields.items():
|
||||
for field_name, field_info in config_eos.__class__.model_fields.items():
|
||||
field_type = field_info.annotation
|
||||
markdown += generate_config_table_md(
|
||||
field_type, [field_name], f"EOS_{field_name.upper()}__", True
|
||||
@@ -279,6 +283,13 @@ def generate_config_md(config_eos: ConfigEOS) -> str:
|
||||
markdown = markdown.rstrip("\n")
|
||||
markdown += "\n"
|
||||
|
||||
# Assure log path does not leak to documentation
|
||||
markdown = re.sub(
|
||||
r'(?<=["\'])/[^"\']*/output/eos\.log(?=["\'])',
|
||||
'/home/user/.local/share/net.akkudoktoreos.net/output/eos.log',
|
||||
markdown
|
||||
)
|
||||
|
||||
return markdown
|
||||
|
||||
|
||||
@@ -298,7 +309,7 @@ def main():
|
||||
try:
|
||||
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("\\\\", "/")
|
||||
config_md = config_md.replace("\\\\", "/")
|
||||
if args.output_file:
|
||||
# Write to file
|
||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
||||
|
@@ -42,6 +42,9 @@ 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
|
||||
|
||||
@@ -58,8 +61,6 @@ 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:
|
||||
|
@@ -286,7 +286,7 @@ def main():
|
||||
try:
|
||||
openapi_md = generate_openapi_md()
|
||||
if os.name == "nt":
|
||||
openapi_md = openapi_md.replace("127.0.0.1", "0.0.0.0")
|
||||
openapi_md = openapi_md.replace("127.0.0.1", "127.0.0.1")
|
||||
if args.output_file:
|
||||
# Write to file
|
||||
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:
|
||||
|
1
scripts/gitlint/eos_commit_rules.py
Normal file
1
scripts/gitlint/eos_commit_rules.py
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder for gitlint user rules (see https://jorisroovers.com/gitlint/latest/rules/user_defined_rules/).
|
@@ -12,15 +12,12 @@ import numpy as np
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.core.ems import get_ems
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.optimization.genetic import (
|
||||
OptimizationParameters,
|
||||
optimization_problem,
|
||||
)
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
|
||||
get_logger(__name__, logging_level="DEBUG")
|
||||
|
||||
|
||||
def prepare_optimization_real_parameters() -> OptimizationParameters:
|
||||
"""Prepare and return optimization parameters with real world data.
|
||||
|
@@ -14,6 +14,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Optional, Type
|
||||
|
||||
from loguru import logger
|
||||
from platformdirs import user_config_dir, user_data_dir
|
||||
from pydantic import Field, computed_field
|
||||
from pydantic_settings import (
|
||||
@@ -29,9 +30,8 @@ from akkudoktoreos.core.cachesettings import CacheCommonSettings
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
from akkudoktoreos.core.decorators import classproperty
|
||||
from akkudoktoreos.core.emsettings import EnergyManagementCommonSettings
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.logsettings import LoggingCommonSettings
|
||||
from akkudoktoreos.core.pydantic import access_nested_value, merge_models
|
||||
from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, merge_models
|
||||
from akkudoktoreos.devices.settings import DevicesCommonSettings
|
||||
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
|
||||
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
|
||||
@@ -44,8 +44,6 @@ from akkudoktoreos.server.server import ServerCommonSettings
|
||||
from akkudoktoreos.utils.datetimeutil import to_timezone
|
||||
from akkudoktoreos.utils.utils import UtilsCommonSettings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_absolute_path(
|
||||
basepath: Optional[Path | str], subpath: Optional[Path | str]
|
||||
@@ -80,10 +78,6 @@ 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
|
||||
@@ -138,7 +132,7 @@ class GeneralSettings(SettingsBaseModel):
|
||||
return self._config_file_path
|
||||
|
||||
|
||||
class SettingsEOS(BaseSettings):
|
||||
class SettingsEOS(BaseSettings, PydanticModelNestedValueMixin):
|
||||
"""Settings for all EOS.
|
||||
|
||||
Used by updating the configuration with specific settings only.
|
||||
@@ -283,6 +277,16 @@ 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,
|
||||
@@ -376,6 +380,15 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
|
||||
def _setup(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Re-initialize global settings."""
|
||||
# Check for config file content/ version type
|
||||
config_file, exists = self._get_config_file_path()
|
||||
if exists:
|
||||
with config_file.open("r", encoding="utf-8", newline=None) as f_config:
|
||||
config_txt = f_config.read()
|
||||
if '"directories": {' in config_txt or '"server_eos_host": ' in config_txt:
|
||||
error_msg = f"Configuration file '{config_file}' is outdated. Please remove or update manually."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
# Assure settings base knows EOS configuration
|
||||
SettingsBaseModel.config = self
|
||||
# (Re-)load settings
|
||||
@@ -394,7 +407,9 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
ValueError: If the `settings` is not a `SettingsEOS` instance.
|
||||
"""
|
||||
if not isinstance(settings, SettingsEOS):
|
||||
raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.")
|
||||
error_msg = f"Settings must be an instance of SettingsEOS: '{settings}'."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
self.merge_settings_from_dict(settings.model_dump(exclude_none=True, exclude_unset=True))
|
||||
|
||||
@@ -426,32 +441,6 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
"""
|
||||
self._setup()
|
||||
|
||||
def set_config_value(self, path: str, value: Any) -> None:
|
||||
"""Set a configuration value based on the provided path.
|
||||
|
||||
Supports string paths (with '/' separators) or sequence paths (list/tuple).
|
||||
Trims leading and trailing '/' from string paths.
|
||||
|
||||
Args:
|
||||
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
|
||||
value (Any]): The value to set.
|
||||
"""
|
||||
access_nested_value(self, path, True, value)
|
||||
|
||||
def get_config_value(self, path: str) -> Any:
|
||||
"""Get a configuration value based on the provided path.
|
||||
|
||||
Supports string paths (with '/' separators) or sequence paths (list/tuple).
|
||||
Trims leading and trailing '/' from string paths.
|
||||
|
||||
Args:
|
||||
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
|
||||
|
||||
Returns:
|
||||
Any: The retrieved value.
|
||||
"""
|
||||
return access_nested_value(self, path, False)
|
||||
|
||||
def _create_initial_config_file(self) -> None:
|
||||
if self.general.config_file_path and not self.general.config_file_path.exists():
|
||||
self.general.config_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -497,10 +486,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
|
||||
@classmethod
|
||||
def _get_config_file_path(cls) -> tuple[Path, bool]:
|
||||
"""Finds the a valid configuration file or returns the desired path for a new config file.
|
||||
"""Find a valid configuration file or return the desired path for a new config file.
|
||||
|
||||
Returns:
|
||||
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there
|
||||
tuple[Path, bool]: The path to the configuration file and if there is already a config file there
|
||||
"""
|
||||
config_dirs = []
|
||||
env_base_dir = os.getenv(cls.EOS_DIR)
|
||||
@@ -529,7 +518,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()
|
||||
json_str = super().model_dump_json(indent=4)
|
||||
f_out.write(json_str)
|
||||
|
||||
def update(self) -> None:
|
||||
|
@@ -27,17 +27,14 @@ 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 compare_datetimes, to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------
|
||||
# In-Memory Caching Functionality
|
||||
# ---------------------------------
|
||||
@@ -956,7 +953,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)
|
||||
result = pickle.load(cache_file) # noqa: S301
|
||||
else:
|
||||
result = cache_file.read()
|
||||
except Exception as e:
|
||||
|
@@ -13,13 +13,10 @@ Classes:
|
||||
import threading
|
||||
from typing import Any, ClassVar, Dict, Optional, Type
|
||||
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import computed_field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
config_eos: Any = None
|
||||
measurement_eos: Any = None
|
||||
prediction_eos: Any = None
|
||||
|
@@ -19,6 +19,7 @@ from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union, over
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pendulum
|
||||
from loguru import logger
|
||||
from numpydantic import NDArray, Shape
|
||||
from pendulum import DateTime, Duration
|
||||
from pydantic import (
|
||||
@@ -31,7 +32,6 @@ from pydantic import (
|
||||
)
|
||||
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin, StartMixin
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import (
|
||||
PydanticBaseModel,
|
||||
PydanticDateTimeData,
|
||||
@@ -39,8 +39,6 @@ from akkudoktoreos.core.pydantic import (
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DataBase(ConfigMixin, StartMixin, PydanticBaseModel):
|
||||
"""Base class for handling generic data.
|
||||
@@ -811,7 +809,8 @@ class DataSequence(DataBase, MutableSequence):
|
||||
dates, values = self.key_to_lists(
|
||||
key=key, start_datetime=start_datetime, end_datetime=end_datetime, dropna=dropna
|
||||
)
|
||||
return pd.Series(data=values, index=pd.DatetimeIndex(dates), name=key)
|
||||
series = pd.Series(data=values, index=pd.DatetimeIndex(dates), name=key)
|
||||
return series
|
||||
|
||||
def key_from_series(self, key: str, series: pd.Series) -> None:
|
||||
"""Update the DataSequence from a Pandas Series.
|
||||
|
@@ -1,10 +1,6 @@
|
||||
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.
|
||||
@@ -34,7 +30,7 @@ class classproperty:
|
||||
argument and returns a value.
|
||||
|
||||
Raises:
|
||||
AssertionError: If `fget` is not defined when `__get__` is called.
|
||||
RuntimeError: If `fget` is not defined when `__get__` is called.
|
||||
"""
|
||||
|
||||
def __init__(self, fget: Callable[[Any], Any]) -> None:
|
||||
@@ -43,5 +39,6 @@ class classproperty:
|
||||
def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any:
|
||||
if owner_cls is None:
|
||||
return self
|
||||
assert self.fget is not None
|
||||
if self.fget is None:
|
||||
raise RuntimeError("'fget' not defined when `__get__` is called")
|
||||
return self.fget(owner_cls)
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import traceback
|
||||
from typing import Any, ClassVar, Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from numpydantic import NDArray, Shape
|
||||
from pendulum import DateTime
|
||||
from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator
|
||||
@@ -8,7 +10,6 @@ from typing_extensions import Self
|
||||
|
||||
from akkudoktoreos.core.cache import CacheUntilUpdateStore
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel, PydanticBaseModel
|
||||
from akkudoktoreos.devices.battery import Battery
|
||||
from akkudoktoreos.devices.generic import HomeAppliance
|
||||
@@ -16,8 +17,6 @@ from akkudoktoreos.devices.inverter import Inverter
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class EnergyManagementParameters(ParametersBaseModel):
|
||||
pv_prognose_wh: list[float] = Field(
|
||||
@@ -282,6 +281,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
self.prediction.update_data(force_enable=force_enable, force_update=force_update)
|
||||
# TODO: Create optimisation problem that calls into devices.update_data() for simulations.
|
||||
|
||||
logger.info("Energy management run (crippled version - prediction update only)")
|
||||
|
||||
def manage_energy(self) -> None:
|
||||
"""Repeating task for managing energy.
|
||||
|
||||
@@ -301,26 +302,28 @@ 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_datetime is None:
|
||||
# Never run before
|
||||
try:
|
||||
# Try to run a first energy management. May fail due to config incomplete.
|
||||
self.run()
|
||||
# Remember energy run datetime.
|
||||
EnergyManagement._last_datetime = current_datetime
|
||||
# Try to run a first energy management. May fail due to config incomplete.
|
||||
self.run()
|
||||
except Exception as e:
|
||||
message = f"EOS init: {e}"
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
message = f"EOS init: {e}\n{trace}"
|
||||
logger.error(message)
|
||||
return
|
||||
|
||||
if self.config.ems.interval is None or self.config.ems.interval == float("nan"):
|
||||
if interval is None or interval == float("nan"):
|
||||
# No Repetition
|
||||
return
|
||||
|
||||
if (
|
||||
compare_datetimes(current_datetime, self._last_datetime).time_diff
|
||||
< self.config.ems.interval
|
||||
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
|
||||
< interval
|
||||
):
|
||||
# Wait for next run
|
||||
return
|
||||
@@ -328,15 +331,16 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
try:
|
||||
self.run()
|
||||
except Exception as e:
|
||||
message = f"EOS run: {e}"
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
message = f"EOS run: {e}\n{trace}"
|
||||
logger.error(message)
|
||||
|
||||
# Remember the energy management run - keep on interval even if we missed some intervals
|
||||
while (
|
||||
compare_datetimes(current_datetime, EnergyManagement._last_datetime).time_diff
|
||||
>= self.config.ems.interval
|
||||
>= interval
|
||||
):
|
||||
EnergyManagement._last_datetime.add(seconds=self.config.ems.interval)
|
||||
EnergyManagement._last_datetime = EnergyManagement._last_datetime.add(seconds=interval)
|
||||
|
||||
def set_start_hour(self, start_hour: Optional[int] = None) -> None:
|
||||
"""Sets start datetime to given hour.
|
||||
@@ -390,7 +394,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
||||
|
||||
# Fetch objects
|
||||
battery = self.battery
|
||||
assert battery # to please mypy
|
||||
if battery is None:
|
||||
raise ValueError(f"battery not set: {battery}")
|
||||
ev = self.ev
|
||||
home_appliance = self.home_appliance
|
||||
inverter = self.inverter
|
||||
|
@@ -1,20 +1,3 @@
|
||||
"""Abstract and base classes for logging."""
|
||||
|
||||
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
|
||||
LOGGING_LEVELS: list[str] = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
|
@@ -1,95 +1,241 @@
|
||||
"""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.
|
||||
"""
|
||||
"""Utility for configuring Loguru loggers."""
|
||||
|
||||
import json
|
||||
import logging as pylogging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from akkudoktoreos.core.logabc import logging_str_to_level
|
||||
import pendulum
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
|
||||
|
||||
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.
|
||||
class InterceptHandler(pylogging.Handler):
|
||||
"""A logging handler that redirects standard Python logging messages to Loguru.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Args:
|
||||
record (logging.LogRecord): A record object containing log message and metadata.
|
||||
"""
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except AttributeError:
|
||||
level = self.loglevel_mapping.get(record.levelno, "INFO")
|
||||
|
||||
frame: Optional[FrameType] = pylogging.currentframe()
|
||||
depth: int = 2
|
||||
while frame and frame.f_code.co_filename == pylogging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
log = logger.bind(request_id="app")
|
||||
log.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
console_handler_id = None
|
||||
file_handler_id = None
|
||||
|
||||
|
||||
def track_logging_config(config_eos: Any, path: str, old_value: Any, value: Any) -> None:
|
||||
"""Track logging config changes."""
|
||||
global console_handler_id, file_handler_id
|
||||
|
||||
if not path.startswith("logging"):
|
||||
raise ValueError(f"Logging shall not track '{path}'")
|
||||
|
||||
if not config_eos.logging.console_level:
|
||||
# No value given - check environment value - may also be None
|
||||
config_eos.logging.console_level = os.getenv("EOS_LOGGING__LEVEL")
|
||||
if not config_eos.logging.file_level:
|
||||
# No value given - check environment value - may also be None
|
||||
config_eos.logging.file_level = os.getenv("EOS_LOGGING__LEVEL")
|
||||
|
||||
# Remove handlers
|
||||
if console_handler_id:
|
||||
try:
|
||||
logger.remove(console_handler_id)
|
||||
except Exception as e:
|
||||
logger.debug("Exception on logger.remove: {}", e, exc_info=True)
|
||||
console_handler_id = None
|
||||
if file_handler_id:
|
||||
try:
|
||||
logger.remove(file_handler_id)
|
||||
except Exception as e:
|
||||
logger.debug("Exception on logger.remove: {}", e, exc_info=True)
|
||||
file_handler_id = None
|
||||
|
||||
# Create handlers with new configuration
|
||||
# Always add console handler
|
||||
if config_eos.logging.console_level not in LOGGING_LEVELS:
|
||||
logger.error(
|
||||
f"Invalid console log level '{config_eos.logging.console_level} - forced to INFO'."
|
||||
)
|
||||
config_eos.logging.console_level = "INFO"
|
||||
|
||||
console_handler_id = logger.add(
|
||||
sys.stderr,
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
level=config_eos.logging.console_level,
|
||||
# format=_console_format
|
||||
)
|
||||
|
||||
# Add file handler
|
||||
if config_eos.logging.file_level and config_eos.logging.file_path:
|
||||
if config_eos.logging.file_level not in LOGGING_LEVELS:
|
||||
logger.error(
|
||||
f"Invalid file log level '{config_eos.logging.console_level}' - forced to INFO."
|
||||
)
|
||||
config_eos.logging.file_level = "INFO"
|
||||
|
||||
file_handler_id = logger.add(
|
||||
sink=config_eos.logging.file_path,
|
||||
rotation="100 MB",
|
||||
retention="3 days",
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
level=config_eos.logging.file_level,
|
||||
serialize=True, # JSON dict formatting
|
||||
# format=_file_format
|
||||
)
|
||||
|
||||
# Redirect standard logging to Loguru
|
||||
pylogging.basicConfig(handlers=[InterceptHandler()], level=0)
|
||||
# Redirect uvicorn and fastapi logging to Loguru
|
||||
pylogging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||
for pylogger_name in ["uvicorn", "uvicorn.error", "fastapi"]:
|
||||
pylogger = pylogging.getLogger(pylogger_name)
|
||||
pylogger.handlers = [InterceptHandler()]
|
||||
pylogger.propagate = False
|
||||
|
||||
logger.info(
|
||||
f"Logger reconfigured - console: {config_eos.logging.console_level}, file: {config_eos.logging.file_level}."
|
||||
)
|
||||
|
||||
|
||||
def read_file_log(
|
||||
log_path: Path,
|
||||
limit: int = 100,
|
||||
level: Optional[str] = None,
|
||||
contains: Optional[str] = None,
|
||||
regex: Optional[str] = None,
|
||||
from_time: Optional[str] = None,
|
||||
to_time: Optional[str] = None,
|
||||
tail: bool = False,
|
||||
) -> List[dict]:
|
||||
"""Read and filter structured log entries from a JSON-formatted log file.
|
||||
|
||||
Args:
|
||||
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.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
logging.Logger: Configured logger instance.
|
||||
List[dict]: A list of filtered log entries as dictionaries.
|
||||
|
||||
Example:
|
||||
logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
|
||||
logger.info("Application started")
|
||||
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.
|
||||
"""
|
||||
# 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)
|
||||
if not log_path.exists():
|
||||
raise FileNotFoundError("Log file not found")
|
||||
|
||||
# The log message format
|
||||
formatter = pylogging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
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}")
|
||||
|
||||
# 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)
|
||||
regex_pattern = re.compile(regex) if regex else None
|
||||
|
||||
# Add the console handler to the logger
|
||||
logger.addHandler(console_handler)
|
||||
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
|
||||
|
||||
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
|
||||
matched_logs = []
|
||||
lines: list[str] = []
|
||||
|
||||
# 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)
|
||||
if tail:
|
||||
with log_path.open("rb") as f:
|
||||
f.seek(0, 2)
|
||||
end = f.tell()
|
||||
buffer = bytearray()
|
||||
pointer = end
|
||||
|
||||
# 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)
|
||||
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()
|
||||
|
||||
# Add the file handler to the logger
|
||||
logger.addHandler(file_handler)
|
||||
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
|
||||
|
||||
return logger
|
||||
return matched_logs
|
||||
|
@@ -3,13 +3,13 @@
|
||||
Kept in an extra module to avoid cyclic dependencies on package import.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logabc import logging_str_to_level
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
|
||||
|
||||
class LoggingCommonSettings(SettingsBaseModel):
|
||||
@@ -17,27 +17,47 @@ class LoggingCommonSettings(SettingsBaseModel):
|
||||
|
||||
level: Optional[str] = Field(
|
||||
default=None,
|
||||
description="EOS default logging level.",
|
||||
examples=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"],
|
||||
deprecated="This is deprecated. Use console_level and file_level instead.",
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("level", mode="after")
|
||||
@classmethod
|
||||
def set_default_logging_level(cls, value: Optional[str]) -> Optional[str]:
|
||||
if isinstance(value, str) and value.upper() == "NONE":
|
||||
value = None
|
||||
if value is None:
|
||||
return None
|
||||
level = logging_str_to_level(value)
|
||||
logging.getLogger().setLevel(level)
|
||||
return value
|
||||
console_level: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Logging level when logging to console.",
|
||||
examples=LOGGING_LEVELS,
|
||||
)
|
||||
|
||||
file_level: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Logging level when logging to file.",
|
||||
examples=LOGGING_LEVELS,
|
||||
)
|
||||
|
||||
# Computed 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
|
||||
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")
|
||||
@classmethod
|
||||
def validate_level(cls, value: Optional[str]) -> Optional[str]:
|
||||
"""Validate logging level string."""
|
||||
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}")
|
||||
return value
|
||||
|
@@ -12,20 +12,35 @@ Key Features:
|
||||
pandas DataFrames and Series with datetime indexes.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
import weakref
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Type,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
)
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pandas as pd
|
||||
import pendulum
|
||||
from loguru import logger
|
||||
from pandas.api.types import is_datetime64_any_dtype
|
||||
from pydantic import (
|
||||
AwareDatetime,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
PrivateAttr,
|
||||
RootModel,
|
||||
TypeAdapter,
|
||||
ValidationError,
|
||||
@@ -35,6 +50,10 @@ from pydantic import (
|
||||
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
# Global weakref dictionary to hold external state per model instance
|
||||
# Used as a workaround for PrivateAttr not working in e.g. Mixin Classes
|
||||
_model_private_state: "weakref.WeakKeyDictionary[Union[PydanticBaseModel, PydanticModelNestedValueMixin], Dict[str, Any]]" = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
def deep_update(source_dict: dict[str, Any], update_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -51,70 +70,6 @@ def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, An
|
||||
return merged_dict
|
||||
|
||||
|
||||
def access_nested_value(
|
||||
model: BaseModel, path: str, setter: bool, value: Optional[Any] = None
|
||||
) -> Any:
|
||||
"""Get or set a nested model value based on the provided path.
|
||||
|
||||
Supports string paths (with '/' separators) or sequence paths (list/tuple).
|
||||
Trims leading and trailing '/' from string paths.
|
||||
|
||||
Args:
|
||||
model (BaseModel): The model object for partial assignment.
|
||||
path (str): The path to the model key (e.g., "key1/key2/key3" or key1/key2/0).
|
||||
setter (bool): True to set value at path, False to return value at path.
|
||||
value (Optional[Any]): The value to set.
|
||||
|
||||
Returns:
|
||||
Any: The retrieved value if acting as a getter, or None if setting a value.
|
||||
"""
|
||||
path_elements = path.strip("/").split("/")
|
||||
|
||||
cfg: Any = model
|
||||
parent: BaseModel = model
|
||||
model_key: str = ""
|
||||
|
||||
for i, key in enumerate(path_elements):
|
||||
is_final_key = i == len(path_elements) - 1
|
||||
|
||||
if isinstance(cfg, list):
|
||||
try:
|
||||
idx = int(key)
|
||||
if is_final_key:
|
||||
if not setter: # Getter
|
||||
return cfg[idx]
|
||||
else: # Setter
|
||||
new_list = list(cfg)
|
||||
new_list[idx] = value
|
||||
# Trigger validation
|
||||
setattr(parent, model_key, new_list)
|
||||
else:
|
||||
cfg = cfg[idx]
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"Error updating model: {e}") from e
|
||||
except (ValueError, IndexError) as e:
|
||||
raise IndexError(f"Invalid list index at {path}: {key}") from e
|
||||
|
||||
elif isinstance(cfg, BaseModel):
|
||||
parent = cfg
|
||||
model_key = key
|
||||
if is_final_key:
|
||||
if not setter: # Getter
|
||||
return getattr(cfg, key)
|
||||
else: # Setter
|
||||
try:
|
||||
# Verification also if nested value is provided opposed to just setattr
|
||||
# Will merge partial assignment
|
||||
cfg = cfg.__pydantic_validator__.validate_assignment(cfg, key, value)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error updating model: {e}") from e
|
||||
else:
|
||||
cfg = getattr(cfg, key)
|
||||
|
||||
else:
|
||||
raise KeyError(f"Key '{key}' not found in model.")
|
||||
|
||||
|
||||
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
|
||||
"""Custom type adapter for Pendulum DateTime fields."""
|
||||
|
||||
@@ -146,11 +101,538 @@ class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
|
||||
return bool(re.match(iso8601_pattern, value))
|
||||
|
||||
|
||||
class PydanticBaseModel(BaseModel):
|
||||
"""Base model class with automatic serialization and deserialization of `pendulum.DateTime` fields.
|
||||
class PydanticModelNestedValueMixin:
|
||||
"""A mixin providing methods to get, set and track nested values within a Pydantic model.
|
||||
|
||||
This model serializes pendulum.DateTime objects to ISO 8601 strings and
|
||||
deserializes ISO 8601 strings to pendulum.DateTime objects.
|
||||
The methods use a '/'-separated path to denote the nested values.
|
||||
Supports handling `Optional`, `List`, and `Dict` types, ensuring correct initialization of
|
||||
missing attributes.
|
||||
|
||||
|
||||
Example:
|
||||
class Address(PydanticBaseModel):
|
||||
city: str
|
||||
|
||||
class User(PydanticBaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
def on_city_change(old, new, path):
|
||||
print(f"{path}: {old} -> {new}")
|
||||
|
||||
user = User(name="Alice", address=Address(city="NY"))
|
||||
user.track_nested_value("address/city", on_city_change)
|
||||
user.set_nested_value("address/city", "LA") # triggers callback
|
||||
|
||||
"""
|
||||
|
||||
def track_nested_value(self, path: str, callback: Callable[[Any, str, Any, Any], None]) -> None:
|
||||
"""Register a callback for a specific path (or subtree).
|
||||
|
||||
Callback triggers if set path is equal or deeper.
|
||||
|
||||
Args:
|
||||
path (str): '/'-separated path to track.
|
||||
callback (callable): Function called as callback(model_instance, set_path, old_value, new_value).
|
||||
"""
|
||||
try:
|
||||
self._validate_path_structure(path)
|
||||
pass
|
||||
except:
|
||||
raise ValueError(f"Path '{path}' is invalid")
|
||||
path = path.strip("/")
|
||||
# Use private data workaround
|
||||
# Should be:
|
||||
# _nested_value_callbacks: dict[str, list[Callable[[str, Any, Any], None]]]
|
||||
# = PrivateAttr(default_factory=dict)
|
||||
nested_value_callbacks = get_private_attr(self, "nested_value_callbacks", dict())
|
||||
if path not in nested_value_callbacks:
|
||||
nested_value_callbacks[path] = []
|
||||
nested_value_callbacks[path].append(callback)
|
||||
set_private_attr(self, "nested_value_callbacks", nested_value_callbacks)
|
||||
|
||||
logger.debug("Nested value callbacks {}", nested_value_callbacks)
|
||||
|
||||
def _validate_path_structure(self, path: str) -> None:
|
||||
"""Validate that a '/'-separated path is structurally valid for this model.
|
||||
|
||||
Checks that each segment of the path corresponds to a field or index in the model's type structure,
|
||||
without requiring that all intermediate values are currently initialized. This method is intended
|
||||
to ensure that the path could be valid for nested access or assignment, according to the model's
|
||||
class definition.
|
||||
|
||||
Args:
|
||||
path (str): The '/'-separated attribute/index path to validate (e.g., "address/city" or "items/0/value").
|
||||
|
||||
Raises:
|
||||
ValueError: If any segment of the path does not correspond to a valid field in the model,
|
||||
or an invalid transition is made (such as an attribute on a non-model).
|
||||
|
||||
Example:
|
||||
class Address(PydanticBaseModel):
|
||||
city: str
|
||||
|
||||
class User(PydanticBaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
user = User(name="Alice", address=Address(city="NY"))
|
||||
user._validate_path_structure("address/city") # OK
|
||||
user._validate_path_structure("address/zipcode") # Raises ValueError
|
||||
"""
|
||||
path_elements = path.strip("/").split("/")
|
||||
# The model we are currently working on
|
||||
model: Any = self
|
||||
# The model we get the type information from. It is a pydantic BaseModel
|
||||
parent: BaseModel = model
|
||||
# The field that provides type information for the current key
|
||||
# Fields may have nested types that translates to a sequence of keys, not just one
|
||||
# - my_field: Optional[list[OtherModel]] -> e.g. "myfield/0" for index 0
|
||||
# parent_key = ["myfield",] ... ["myfield", "0"]
|
||||
# parent_key_types = [list, OtherModel]
|
||||
parent_key: list[str] = []
|
||||
parent_key_types: list = []
|
||||
|
||||
for i, key in enumerate(path_elements):
|
||||
is_final_key = i == len(path_elements) - 1
|
||||
# Add current key to parent key to enable nested type tracking
|
||||
parent_key.append(key)
|
||||
|
||||
# Get next value
|
||||
next_value = None
|
||||
if isinstance(model, BaseModel):
|
||||
# Track parent and key for possible assignment later
|
||||
parent = model
|
||||
parent_key = [
|
||||
key,
|
||||
]
|
||||
parent_key_types = self._get_key_types(model.__class__, key)
|
||||
|
||||
# If this is the final key, set the value
|
||||
if is_final_key:
|
||||
return
|
||||
|
||||
# Attempt to access the next attribute, handling None values
|
||||
next_value = getattr(model, key, None)
|
||||
|
||||
# Handle missing values (initialize dict/list/model if necessary)
|
||||
if next_value is None:
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
next_value = self._initialize_value(next_type)
|
||||
|
||||
elif isinstance(model, list):
|
||||
# Handle lists
|
||||
try:
|
||||
idx = int(key)
|
||||
except Exception as e:
|
||||
raise IndexError(
|
||||
f"Invalid list index '{key}' at '{path}': key = '{key}'; parent = '{parent}', parent_key = '{parent_key}'; model = '{model}'; {e}"
|
||||
)
|
||||
|
||||
# Get next type from parent key type information
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
|
||||
if len(model) > idx:
|
||||
next_value = model[idx]
|
||||
else:
|
||||
return
|
||||
|
||||
if is_final_key:
|
||||
return
|
||||
|
||||
elif isinstance(model, dict):
|
||||
# Handle dictionaries (auto-create missing keys)
|
||||
|
||||
# Get next type from parent key type information
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
|
||||
if is_final_key:
|
||||
return
|
||||
|
||||
if key not in model:
|
||||
return
|
||||
else:
|
||||
next_value = model[key]
|
||||
|
||||
else:
|
||||
raise KeyError(f"Key '{key}' not found in model.")
|
||||
|
||||
# Move deeper
|
||||
model = next_value
|
||||
|
||||
def get_nested_value(self, path: str) -> Any:
|
||||
"""Retrieve a nested value from the model using a '/'-separated path.
|
||||
|
||||
Supports accessing nested attributes and list indices.
|
||||
|
||||
Args:
|
||||
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
|
||||
|
||||
Returns:
|
||||
Any: The retrieved value.
|
||||
|
||||
Raises:
|
||||
KeyError: If a key is not found in the model.
|
||||
IndexError: If a list index is out of bounds or invalid.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class Address(PydanticBaseModel):
|
||||
city: str
|
||||
|
||||
class User(PydanticBaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
user = User(name="Alice", address=Address(city="New York"))
|
||||
city = user.get_nested_value("address/city")
|
||||
print(city) # Output: "New York"
|
||||
```
|
||||
"""
|
||||
path_elements = path.strip("/").split("/")
|
||||
model: Any = self
|
||||
|
||||
for key in path_elements:
|
||||
if isinstance(model, list):
|
||||
try:
|
||||
model = model[int(key)]
|
||||
except (ValueError, IndexError) as e:
|
||||
raise IndexError(f"Invalid list index at '{path}': {key}; {e}")
|
||||
elif isinstance(model, dict):
|
||||
try:
|
||||
model = model[key]
|
||||
except Exception as e:
|
||||
raise KeyError(f"Invalid dict key at '{path}': {key}; {e}")
|
||||
elif isinstance(model, BaseModel):
|
||||
model = getattr(model, key)
|
||||
else:
|
||||
raise KeyError(f"Key '{key}' not found in model.")
|
||||
|
||||
return model
|
||||
|
||||
def set_nested_value(self, path: str, value: Any) -> None:
|
||||
"""Set a nested value in the model using a '/'-separated path.
|
||||
|
||||
Supports modifying nested attributes and list indices while preserving Pydantic validation.
|
||||
Automatically initializes missing `Optional`, `Union`, `dict`, and `list` fields if necessary.
|
||||
If a missing field cannot be initialized, raises an exception.
|
||||
|
||||
Triggers the callbacks registered by track_nested_value().
|
||||
|
||||
Args:
|
||||
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
|
||||
value (Any): The new value to set.
|
||||
|
||||
Raises:
|
||||
KeyError: If a key is not found in the model.
|
||||
IndexError: If a list index is out of bounds or invalid.
|
||||
ValueError: If a validation error occurs.
|
||||
TypeError: If a missing field cannot be initialized.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class Address(PydanticBaseModel):
|
||||
city: Optional[str]
|
||||
|
||||
class User(PydanticBaseModel):
|
||||
name: str
|
||||
address: Optional[Address]
|
||||
settings: Optional[Dict[str, Any]]
|
||||
|
||||
user = User(name="Alice", address=None, settings=None)
|
||||
user.set_nested_value("address/city", "Los Angeles")
|
||||
user.set_nested_value("settings/theme", "dark")
|
||||
|
||||
print(user.address.city) # Output: "Los Angeles"
|
||||
print(user.settings) # Output: {'theme': 'dark'}
|
||||
```
|
||||
"""
|
||||
path = path.strip("/")
|
||||
# Store old value (if possible)
|
||||
try:
|
||||
old_value = self.get_nested_value(path)
|
||||
except Exception as e:
|
||||
# We can not get the old value
|
||||
# raise ValueError(f"Can not get old (current) value of '{path}': {e}") from e
|
||||
old_value = None
|
||||
|
||||
# Proceed with core logic
|
||||
self._set_nested_value(path, value)
|
||||
|
||||
# Trigger all callbacks whose path is a prefix of set path
|
||||
triggered = set()
|
||||
nested_value_callbacks = get_private_attr(self, "nested_value_callbacks", dict())
|
||||
for cb_path, callbacks in nested_value_callbacks.items():
|
||||
# Match: cb_path == path, or cb_path is a prefix (parent) of path
|
||||
pass
|
||||
if path == cb_path or path.startswith(cb_path + "/"):
|
||||
for cb in callbacks:
|
||||
# Prevent duplicate calls
|
||||
if (cb_path, id(cb)) not in triggered:
|
||||
cb(self, path, old_value, value)
|
||||
triggered.add((cb_path, id(cb)))
|
||||
|
||||
def _set_nested_value(self, path: str, value: Any) -> None:
|
||||
"""Set a nested value core logic.
|
||||
|
||||
Args:
|
||||
path (str): A '/'-separated path to the nested attribute (e.g., "key1/key2/0").
|
||||
value (Any): The new value to set.
|
||||
|
||||
Raises:
|
||||
KeyError: If a key is not found in the model.
|
||||
IndexError: If a list index is out of bounds or invalid.
|
||||
ValueError: If a validation error occurs.
|
||||
TypeError: If a missing field cannot be initialized.
|
||||
"""
|
||||
path_elements = path.strip("/").split("/")
|
||||
# The model we are currently working on
|
||||
model: Any = self
|
||||
# The model we get the type information from. It is a pydantic BaseModel
|
||||
parent: BaseModel = model
|
||||
# The field that provides type information for the current key
|
||||
# Fields may have nested types that translates to a sequence of keys, not just one
|
||||
# - my_field: Optional[list[OtherModel]] -> e.g. "myfield/0" for index 0
|
||||
# parent_key = ["myfield",] ... ["myfield", "0"]
|
||||
# parent_key_types = [list, OtherModel]
|
||||
parent_key: list[str] = []
|
||||
parent_key_types: list = []
|
||||
|
||||
for i, key in enumerate(path_elements):
|
||||
is_final_key = i == len(path_elements) - 1
|
||||
# Add current key to parent key to enable nested type tracking
|
||||
parent_key.append(key)
|
||||
|
||||
# Get next value
|
||||
next_value = None
|
||||
if isinstance(model, BaseModel):
|
||||
# Track parent and key for possible assignment later
|
||||
parent = model
|
||||
parent_key = [
|
||||
key,
|
||||
]
|
||||
parent_key_types = self._get_key_types(model.__class__, key)
|
||||
|
||||
# If this is the final key, set the value
|
||||
if is_final_key:
|
||||
try:
|
||||
model.__pydantic_validator__.validate_assignment(model, key, value)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"Error updating model: {e}") from e
|
||||
return
|
||||
|
||||
# Attempt to access the next attribute, handling None values
|
||||
next_value = getattr(model, key, None)
|
||||
|
||||
# Handle missing values (initialize dict/list/model if necessary)
|
||||
if next_value is None:
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
next_value = self._initialize_value(next_type)
|
||||
if next_value is None:
|
||||
raise TypeError(
|
||||
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
|
||||
)
|
||||
setattr(parent, key, next_value)
|
||||
# pydantic may copy on validation assignment - reread to get the copied model
|
||||
next_value = getattr(model, key, None)
|
||||
|
||||
elif isinstance(model, list):
|
||||
# Handle lists (ensure index exists and modify safely)
|
||||
try:
|
||||
idx = int(key)
|
||||
except Exception as e:
|
||||
raise IndexError(
|
||||
f"Invalid list index '{key}' at '{path}': key = '{key}'; parent = '{parent}', parent_key = '{parent_key}'; model = '{model}'; {e}"
|
||||
)
|
||||
|
||||
# Get next type from parent key type information
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
|
||||
if len(model) > idx:
|
||||
next_value = model[idx]
|
||||
else:
|
||||
# Extend the list with default values if index is out of range
|
||||
while len(model) <= idx:
|
||||
next_value = self._initialize_value(next_type)
|
||||
if next_value is None:
|
||||
raise TypeError(
|
||||
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
|
||||
)
|
||||
model.append(next_value)
|
||||
|
||||
if is_final_key:
|
||||
if (
|
||||
(isinstance(next_type, type) and not isinstance(value, next_type))
|
||||
or (next_type is dict and not isinstance(value, dict))
|
||||
or (next_type is list and not isinstance(value, list))
|
||||
):
|
||||
raise TypeError(
|
||||
f"Expected type {next_type} for key '{key}' in path '{path}', but got {type(value)}: {value}"
|
||||
)
|
||||
model[idx] = value
|
||||
return
|
||||
|
||||
elif isinstance(model, dict):
|
||||
# Handle dictionaries (auto-create missing keys)
|
||||
|
||||
# Get next type from parent key type information
|
||||
next_type = parent_key_types[len(parent_key) - 1]
|
||||
|
||||
if is_final_key:
|
||||
if (
|
||||
(isinstance(next_type, type) and not isinstance(value, next_type))
|
||||
or (next_type is dict and not isinstance(value, dict))
|
||||
or (next_type is list and not isinstance(value, list))
|
||||
):
|
||||
raise TypeError(
|
||||
f"Expected type {next_type} for key '{key}' in path '{path}', but got {type(value)}: {value}"
|
||||
)
|
||||
model[key] = value
|
||||
return
|
||||
|
||||
if key not in model:
|
||||
next_value = self._initialize_value(next_type)
|
||||
if next_value is None:
|
||||
raise TypeError(
|
||||
f"Unable to initialize missing value for key '{key}' in path '{path}' with type {next_type} of {parent_key}:{parent_key_types}."
|
||||
)
|
||||
model[key] = next_value
|
||||
else:
|
||||
next_value = model[key]
|
||||
|
||||
else:
|
||||
raise KeyError(f"Key '{key}' not found in model.")
|
||||
|
||||
# Move deeper
|
||||
model = next_value
|
||||
|
||||
@staticmethod
|
||||
def _get_key_types(model: Type[BaseModel], key: str) -> List[Union[Type[Any], list, dict]]:
|
||||
"""Returns a list of nested types for a given Pydantic model key.
|
||||
|
||||
- Skips `Optional` and `Union`, using only the first non-None type.
|
||||
- Skips dictionary keys and only adds value types.
|
||||
- Keeps `list` and `dict` as origins.
|
||||
|
||||
Args:
|
||||
model (Type[BaseModel]): The Pydantic model class to inspect.
|
||||
key (str): The attribute name in the model.
|
||||
|
||||
Returns:
|
||||
List[Union[Type[Any], list, dict]]: A list of extracted types, preserving `list` and `dict` origins.
|
||||
|
||||
Raises:
|
||||
TypeError: If the key does not exist or lacks a valid type annotation.
|
||||
"""
|
||||
if not inspect.isclass(model):
|
||||
raise TypeError(f"Model '{model}' is not of class type.")
|
||||
|
||||
if key not in model.model_fields:
|
||||
raise TypeError(f"Field '{key}' does not exist in model '{model.__name__}'.")
|
||||
|
||||
field_annotation = model.model_fields[key].annotation
|
||||
if not field_annotation:
|
||||
raise TypeError(
|
||||
f"Missing type annotation for field '{key}' in model '{model.__name__}'."
|
||||
)
|
||||
|
||||
nested_types: list[Union[Type[Any], list, dict]] = []
|
||||
queue: list[Any] = [field_annotation]
|
||||
|
||||
while queue:
|
||||
annotation = queue.pop(0)
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
|
||||
# Handle Union (Optional[X] is treated as Union[X, None])
|
||||
if origin is Union:
|
||||
queue.extend(arg for arg in args if arg is not type(None))
|
||||
continue
|
||||
|
||||
# Handle lists and dictionaries
|
||||
if origin is list:
|
||||
nested_types.append(list)
|
||||
if args:
|
||||
queue.append(args[0]) # Extract value type for list[T]
|
||||
continue
|
||||
|
||||
if origin is dict:
|
||||
nested_types.append(dict)
|
||||
if len(args) == 2:
|
||||
queue.append(args[1]) # Extract only the value type for dict[K, V]
|
||||
continue
|
||||
|
||||
# If it's a BaseModel, add it to the list
|
||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||
nested_types.append(annotation)
|
||||
continue
|
||||
|
||||
# Otherwise, it's a standard type (e.g., str, int, bool, float, etc.)
|
||||
nested_types.append(annotation)
|
||||
|
||||
return nested_types
|
||||
|
||||
@staticmethod
|
||||
def _initialize_value(type_hint: Type[Any] | None | list[Any] | dict[Any, Any]) -> Any:
|
||||
"""Initialize a missing value based on the provided type hint.
|
||||
|
||||
Args:
|
||||
type_hint (Type[Any] | None | list[Any] | dict[Any, Any]): The type hint that determines
|
||||
how the missing value should be initialized.
|
||||
|
||||
Returns:
|
||||
Any: An instance of the expected type (e.g., list, dict, or Pydantic model), or `None`
|
||||
if initialization is not possible.
|
||||
|
||||
Raises:
|
||||
TypeError: If instantiation fails.
|
||||
|
||||
Example:
|
||||
- For `list[str]`, returns `[]`
|
||||
- For `dict[str, Any]`, returns `{}`
|
||||
- For `Address` (a Pydantic model), returns a new `Address()` instance.
|
||||
"""
|
||||
if type_hint is None:
|
||||
return None
|
||||
|
||||
# Handle direct instances of list or dict
|
||||
if isinstance(type_hint, list):
|
||||
return []
|
||||
if isinstance(type_hint, dict):
|
||||
return {}
|
||||
|
||||
origin = get_origin(type_hint)
|
||||
|
||||
# Handle generic list and dictionary
|
||||
if origin is list:
|
||||
return []
|
||||
if origin is dict:
|
||||
return {}
|
||||
|
||||
# Handle Pydantic models
|
||||
if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
|
||||
try:
|
||||
return type_hint.model_construct()
|
||||
except Exception as e:
|
||||
raise TypeError(f"Failed to initialize model '{type_hint.__name__}': {e}")
|
||||
|
||||
# Handle standard built-in types (int, float, str, bool, etc.)
|
||||
if isinstance(type_hint, type):
|
||||
try:
|
||||
return type_hint()
|
||||
except Exception as e:
|
||||
raise TypeError(f"Failed to initialize instance of '{type_hint.__name__}': {e}")
|
||||
|
||||
raise TypeError(f"Unsupported type hint '{type_hint}' for initialization.")
|
||||
|
||||
|
||||
class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
"""Base model with pendulum datetime support, nested value utilities, and stable hashing.
|
||||
|
||||
This class provides:
|
||||
- ISO 8601 serialization/deserialization of `pendulum.DateTime` fields.
|
||||
- Nested attribute access and mutation via `PydanticModelNestedValueMixin`.
|
||||
- A consistent hash using a UUID for use in sets and as dictionary keys
|
||||
"""
|
||||
|
||||
# Enable custom serialization globally in config
|
||||
@@ -160,6 +642,17 @@ class PydanticBaseModel(BaseModel):
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
_uuid: str = PrivateAttr(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""str: A private UUID string generated on instantiation, used for hashing."""
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Returns a stable hash based on the instance's UUID.
|
||||
|
||||
Returns:
|
||||
int: Hash value derived from the model's UUID.
|
||||
"""
|
||||
return hash(self._uuid)
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
def validate_and_convert_pendulum(cls, value: Any, info: ValidationInfo) -> Any:
|
||||
"""Validator to convert fields of type `pendulum.DateTime`.
|
||||
@@ -188,8 +681,8 @@ class PydanticBaseModel(BaseModel):
|
||||
if expected_type is pendulum.DateTime or expected_type is AwareDatetime:
|
||||
try:
|
||||
value = to_datetime(value)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ValueError(f"Cannot convert {value!r} to datetime: {e}")
|
||||
return value
|
||||
|
||||
# Override Pydantic’s serialization for all DateTime fields
|
||||
@@ -577,3 +1070,27 @@ class PydanticDateTimeSeries(PydanticBaseModel):
|
||||
|
||||
class ParametersBaseModel(PydanticBaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def set_private_attr(
|
||||
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str, value: Any
|
||||
) -> None:
|
||||
"""Set a private attribute for a model instance (not stored in model itself)."""
|
||||
if model not in _model_private_state:
|
||||
_model_private_state[model] = {}
|
||||
_model_private_state[model][key] = value
|
||||
|
||||
|
||||
def get_private_attr(
|
||||
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str, default: Any = None
|
||||
) -> Any:
|
||||
"""Get a private attribute or return default."""
|
||||
return _model_private_state.get(model, {}).get(key, default)
|
||||
|
||||
|
||||
def del_private_attr(
|
||||
model: Union[PydanticBaseModel, PydanticModelNestedValueMixin], key: str
|
||||
) -> None:
|
||||
"""Delete a private attribute."""
|
||||
if model in _model_private_state and key in _model_private_state[model]:
|
||||
del _model_private_state[model][key]
|
||||
|
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -11,8 +10,6 @@ from akkudoktoreos.devices.devicesabc import (
|
||||
)
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def max_charging_power_field(description: Optional[str] = None) -> float:
|
||||
if description is None:
|
||||
@@ -121,7 +118,8 @@ class Battery(DeviceBase):
|
||||
|
||||
def _setup(self) -> None:
|
||||
"""Sets up the battery parameters based on configuration or provided parameters."""
|
||||
assert self.parameters is not None
|
||||
if self.parameters is None:
|
||||
raise ValueError(f"Parameters not set: {self.parameters}")
|
||||
self.capacity_wh = self.parameters.capacity_wh
|
||||
self.initial_soc_percentage = self.parameters.initial_soc_percentage
|
||||
self.charging_efficiency = self.parameters.charging_efficiency
|
||||
|
@@ -1,15 +1,12 @@
|
||||
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):
|
||||
@@ -39,10 +36,14 @@ class Devices(SingletonMixin, DevicesBase):
|
||||
device.post_setup()
|
||||
|
||||
|
||||
# Initialize the Devices simulation, it is a singleton.
|
||||
devices = Devices()
|
||||
# Initialize the Devices simulation, it is a singleton.
|
||||
devices: Optional[Devices] = None
|
||||
|
||||
|
||||
def get_devices() -> Devices:
|
||||
global devices
|
||||
# Fix circular import at runtime
|
||||
if devices is None:
|
||||
devices = Devices()
|
||||
"""Gets the EOS Devices simulation."""
|
||||
return devices
|
||||
|
@@ -3,6 +3,7 @@
|
||||
from enum import Enum
|
||||
from typing import Optional, Type
|
||||
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
@@ -12,12 +13,9 @@ from akkudoktoreos.core.coreabc import (
|
||||
EnergyManagementSystemMixin,
|
||||
PredictionMixin,
|
||||
)
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DeviceParameters(ParametersBaseModel):
|
||||
device_id: str = Field(description="ID of device", examples="device1")
|
||||
@@ -170,7 +168,8 @@ class DevicesBase(DevicesStartEndMixin, PredictionMixin):
|
||||
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"
|
||||
if device.device_id in self.devices:
|
||||
raise ValueError(f"{device.device_id} already registered")
|
||||
self.devices[device.device_id] = device
|
||||
|
||||
def remove_device(self, device: Type["DeviceBase"] | str) -> bool:
|
||||
|
@@ -3,11 +3,8 @@ 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."""
|
||||
@@ -34,7 +31,8 @@ class HomeAppliance(DeviceBase):
|
||||
super().__init__(parameters)
|
||||
|
||||
def _setup(self) -> None:
|
||||
assert self.parameters is not None
|
||||
if self.parameters is None:
|
||||
raise ValueError(f"Parameters not set: {self.parameters}")
|
||||
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
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from typing import List, Sequence
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class Heatpump:
|
||||
@@ -22,7 +22,6 @@ class Heatpump:
|
||||
def __init__(self, max_heat_output: int, hours: int):
|
||||
self.max_heat_output = max_heat_output
|
||||
self.hours = hours
|
||||
self.log = get_logger(__name__)
|
||||
|
||||
def __check_outside_temperature_range__(self, temp_celsius: float) -> bool:
|
||||
"""Check if temperature is in valid range between -100 and 100 degree Celsius.
|
||||
@@ -59,7 +58,7 @@ class Heatpump:
|
||||
f"Outside temperature '{outside_temperature_celsius}' not in range "
|
||||
"(min: -100 Celsius, max: 100 Celsius)"
|
||||
)
|
||||
self.log.error(err_msg)
|
||||
logger.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
def calculate_heating_output(self, outside_temperature_celsius: float) -> float:
|
||||
@@ -87,7 +86,7 @@ class Heatpump:
|
||||
f"Outside temperature '{outside_temperature_celsius}' not in range "
|
||||
"(min: -100 Celsius, max: 100 Celsius)"
|
||||
)
|
||||
self.log.error(err_msg)
|
||||
logger.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
def calculate_heat_power(self, outside_temperature_celsius: float) -> float:
|
||||
@@ -111,7 +110,7 @@ class Heatpump:
|
||||
f"Outside temperature '{outside_temperature_celsius}' not in range "
|
||||
"(min: -100 Celsius, max: 100 Celsius)"
|
||||
)
|
||||
self.log.error(err_msg)
|
||||
logger.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
def simulate_24h(self, temperatures: Sequence[float]) -> List[float]:
|
||||
|
@@ -1,13 +1,11 @@
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters
|
||||
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InverterParameters(DeviceParameters):
|
||||
"""Inverter Device Simulation Configuration."""
|
||||
@@ -27,8 +25,25 @@ class Inverter(DeviceBase):
|
||||
self.parameters: Optional[InverterParameters] = None
|
||||
super().__init__(parameters)
|
||||
|
||||
self.scr_lookup: dict = {}
|
||||
|
||||
def _calculate_scr(self, consumption: float, generation: float) -> float:
|
||||
"""Check if the consumption and production is in the lookup table. If not, calculate and store the value."""
|
||||
if consumption not in self.scr_lookup:
|
||||
self.scr_lookup[consumption] = {}
|
||||
|
||||
if generation not in self.scr_lookup[consumption]:
|
||||
scr = self.self_consumption_predictor.calculate_self_consumption(
|
||||
consumption, generation
|
||||
)
|
||||
self.scr_lookup[consumption][generation] = scr
|
||||
return scr
|
||||
|
||||
return self.scr_lookup[consumption][generation]
|
||||
|
||||
def _setup(self) -> None:
|
||||
assert self.parameters is not None
|
||||
if self.parameters is None:
|
||||
raise ValueError(f"Parameters not set: {self.parameters}")
|
||||
if self.parameters.battery_id is None:
|
||||
# For the moment raise exception
|
||||
# TODO: Make battery configurable by config
|
||||
@@ -41,7 +56,8 @@ class Inverter(DeviceBase):
|
||||
) # Maximum power that the inverter can handle
|
||||
|
||||
def _post_setup(self) -> None:
|
||||
assert self.parameters is not None
|
||||
if self.parameters is None:
|
||||
raise ValueError(f"Parameters not set: {self.parameters}")
|
||||
self.battery = self.devices.get_device_by_id(self.parameters.battery_id)
|
||||
|
||||
def process_energy(
|
||||
@@ -60,9 +76,8 @@ class Inverter(DeviceBase):
|
||||
grid_import = -remaining_power # Negative indicates feeding into the grid
|
||||
self_consumption = self.max_power_wh
|
||||
else:
|
||||
scr = self.self_consumption_predictor.calculate_self_consumption(
|
||||
consumption, generation
|
||||
)
|
||||
# Calculate scr with lookup table
|
||||
scr = self._calculate_scr(consumption, generation)
|
||||
|
||||
# Remaining power after consumption
|
||||
remaining_power = (generation - consumption) * scr # EVQ
|
||||
|
@@ -3,13 +3,10 @@ from typing import Optional
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.devices.battery import BaseBatteryParameters
|
||||
from akkudoktoreos.devices.generic import HomeApplianceParameters
|
||||
from akkudoktoreos.devices.inverter import InverterParameters
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DevicesCommonSettings(SettingsBaseModel):
|
||||
"""Base configuration for devices simulation settings."""
|
||||
|
@@ -9,6 +9,7 @@ The measurements can be added programmatically or imported from a file or JSON s
|
||||
from typing import Any, ClassVar, List, Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from numpydantic import NDArray, Shape
|
||||
from pendulum import DateTime, Duration
|
||||
from pydantic import Field, computed_field
|
||||
@@ -16,11 +17,8 @@ from pydantic import Field, computed_field
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
from akkudoktoreos.core.dataabc import DataImportMixin, DataRecord, DataSequence
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MeasurementCommonSettings(SettingsBaseModel):
|
||||
"""Measurement Configuration."""
|
||||
|
@@ -4,6 +4,7 @@ from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from deap import algorithms, base, creator, tools
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -13,7 +14,6 @@ from akkudoktoreos.core.coreabc import (
|
||||
EnergyManagementSystemMixin,
|
||||
)
|
||||
from akkudoktoreos.core.ems import EnergyManagementParameters, SimulationResult
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
||||
from akkudoktoreos.devices.battery import (
|
||||
Battery,
|
||||
@@ -25,8 +25,6 @@ from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
|
||||
from akkudoktoreos.devices.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OptimizationParameters(ParametersBaseModel):
|
||||
ems: EnergyManagementParameters
|
||||
@@ -121,7 +119,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
|
||||
if self.fix_seed is not None:
|
||||
random.seed(self.fix_seed)
|
||||
elif logger.level == "DEBUG":
|
||||
self.fix_seed = random.randint(1, 100000000000)
|
||||
self.fix_seed = random.randint(1, 100000000000) # noqa: S311
|
||||
random.seed(self.fix_seed)
|
||||
|
||||
def decode_charge_discharge(
|
||||
|
@@ -3,9 +3,6 @@ from typing import List, Optional
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OptimizationCommonSettings(SettingsBaseModel):
|
||||
|
@@ -3,11 +3,8 @@
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OptimizationBase(ConfigMixin, PredictionMixin, PydanticBaseModel):
|
||||
"""Base class for handling optimization data.
|
||||
|
@@ -1,9 +1,20 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
# Valid elecprice providers
|
||||
elecprice_providers = [
|
||||
provider.provider_id()
|
||||
for provider in prediction_eos.providers
|
||||
if isinstance(provider, ElecPriceProvider)
|
||||
]
|
||||
|
||||
|
||||
class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
@@ -17,7 +28,23 @@ class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
charges_kwh: Optional[float] = Field(
|
||||
default=None, ge=0, description="Electricity price charges (€/kWh).", examples=[0.21]
|
||||
)
|
||||
vat_rate: Optional[float] = Field(
|
||||
default=1.19,
|
||||
ge=0,
|
||||
description="VAT rate factor applied to electricity price when charges are used.",
|
||||
examples=[1.19],
|
||||
)
|
||||
|
||||
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
|
||||
default=None, description="Provider settings", examples=[None]
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("provider", mode="after")
|
||||
@classmethod
|
||||
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None or value in elecprice_providers:
|
||||
return value
|
||||
raise ValueError(
|
||||
f"Provider '{value}' is not a valid electricity price provider: {elecprice_providers}."
|
||||
)
|
||||
|
@@ -9,11 +9,8 @@ from typing import List, Optional
|
||||
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ElecPriceDataRecord(PredictionRecord):
|
||||
"""Represents a electricity price data record containing various price attributes at a specific datetime.
|
||||
|
@@ -11,17 +11,15 @@ from typing import Any, List, Optional, Union
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AkkudoktorElecPriceMeta(PydanticBaseModel):
|
||||
start_timestamp: str
|
||||
@@ -104,12 +102,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
- add the file cache again.
|
||||
"""
|
||||
source = "https://api.akkudoktor.net"
|
||||
assert self.start_datetime # mypy fix
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
# Try to take data from 5 weeks back for prediction
|
||||
date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.general.timezone}"
|
||||
response = requests.get(url)
|
||||
response = requests.get(url, timeout=10)
|
||||
logger.debug(f"Response from {url}: {response}")
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
akkudoktor_data = self._validate_data(response.content)
|
||||
@@ -148,7 +147,8 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
"""
|
||||
# Get Akkudoktor electricity price data
|
||||
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
|
||||
assert self.start_datetime # mypy fix
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
@@ -178,7 +178,10 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
||||
)
|
||||
|
||||
amount_datasets = len(self.records)
|
||||
assert highest_orig_datetime # mypy fix
|
||||
if not highest_orig_datetime: # mypy fix
|
||||
error_msg = f"Highest original datetime not available: {highest_orig_datetime}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
|
||||
needed_hours = int(
|
||||
|
257
src/akkudoktoreos/prediction/elecpriceenergycharts.py
Normal file
257
src/akkudoktoreos/prediction/elecpriceenergycharts.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Retrieves and processes electricity price forecast data from Energy-Charts.
|
||||
|
||||
This module provides classes and mappings to manage electricity price data obtained from the
|
||||
Energy-Charts API, including support for various electricity price attributes such as temperature,
|
||||
humidity, cloud cover, and solar irradiance. The data is mapped to the `ElecPriceDataRecord`
|
||||
format, enabling consistent access to forecasted and historical electricity price attributes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
class EnergyChartsElecPrice(PydanticBaseModel):
|
||||
license_info: str
|
||||
unix_seconds: List[int]
|
||||
price: List[float]
|
||||
unit: str
|
||||
deprecated: bool
|
||||
|
||||
|
||||
class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
"""Fetch and process electricity price forecast data from Energy-Charts.
|
||||
|
||||
ElecPriceEnergyCharts is a singleton-based class that retrieves electricity price forecast data
|
||||
from the Energy-Charts API and maps it to `ElecPriceDataRecord` fields, applying
|
||||
any necessary scaling or unit corrections. It manages the forecast over a range
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
|
||||
|
||||
Methods:
|
||||
provider_id(): Returns a unique identifier for the provider.
|
||||
_request_forecast(): Fetches the forecast from the Energy-Charts API.
|
||||
_update_data(): Processes and updates forecast data from Energy-Charts in ElecPriceDataRecord format.
|
||||
"""
|
||||
|
||||
highest_orig_datetime: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the Energy-Charts provider."""
|
||||
return "ElecPriceEnergyCharts"
|
||||
|
||||
@classmethod
|
||||
def _validate_data(cls, json_str: Union[bytes, Any]) -> EnergyChartsElecPrice:
|
||||
"""Validate Energy-Charts Electricity Price forecast data."""
|
||||
try:
|
||||
energy_charts_data = EnergyChartsElecPrice.model_validate_json(json_str)
|
||||
except ValidationError as e:
|
||||
error_msg = ""
|
||||
for error in e.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
message = error["msg"]
|
||||
error_type = error["type"]
|
||||
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
|
||||
logger.error(f"Energy-Charts schema change: {error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
return energy_charts_data
|
||||
|
||||
@cache_in_file(with_ttl="1 hour")
|
||||
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
|
||||
"""Fetch electricity price forecast data from Energy-Charts API.
|
||||
|
||||
This method sends a request to Energy-Charts API to retrieve forecast data for a specified
|
||||
date range. The response data is parsed and returned as JSON for further processing.
|
||||
|
||||
Returns:
|
||||
dict: The parsed JSON response from Energy-Charts API containing forecast data.
|
||||
|
||||
Raises:
|
||||
ValueError: If the API response does not include expected `electricity price` data.
|
||||
"""
|
||||
source = "https://api.energy-charts.info"
|
||||
if start_date is None:
|
||||
# Try to take data from 5 weeks back for prediction
|
||||
start_date = to_datetime(
|
||||
self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD"
|
||||
)
|
||||
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
url = f"{source}/price?bzn=DE-LU&start={start_date}&end={last_date}"
|
||||
response = requests.get(url, timeout=30)
|
||||
logger.debug(f"Response from {url}: {response}")
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
energy_charts_data = self._validate_data(response.content)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return energy_charts_data
|
||||
|
||||
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
|
||||
# Get charges_kwh in wh
|
||||
charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
|
||||
|
||||
# Initialize
|
||||
highest_orig_datetime = None # newest datetime from the api after that we want to update.
|
||||
series_data = pd.Series(dtype=float) # Initialize an empty series
|
||||
|
||||
# Iterate over timestamps and prices together
|
||||
for unix_sec, price_eur_per_mwh in zip(
|
||||
energy_charts_data.unix_seconds, energy_charts_data.price
|
||||
):
|
||||
orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone)
|
||||
|
||||
# Track the latest datetime
|
||||
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
|
||||
highest_orig_datetime = orig_datetime
|
||||
|
||||
# Convert EUR/MWh to EUR/Wh, apply charges and VAT if charges > 0
|
||||
if charges_wh > 0:
|
||||
vat_rate = self.config.elecprice.vat_rate or 1.19
|
||||
price_wh = ((price_eur_per_mwh / 1_000_000) + charges_wh) * vat_rate
|
||||
else:
|
||||
price_wh = price_eur_per_mwh / 1_000_000
|
||||
|
||||
# Store in series
|
||||
series_data.at[orig_datetime] = price_wh
|
||||
|
||||
return series_data
|
||||
|
||||
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
|
||||
mean = data.mean()
|
||||
std = data.std()
|
||||
lower_bound = mean - sigma * std
|
||||
upper_bound = mean + sigma * std
|
||||
capped_data = data.clip(min=lower_bound, max=upper_bound)
|
||||
return capped_data
|
||||
|
||||
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
model = ExponentialSmoothing(
|
||||
clean_history, seasonal="add", seasonal_periods=seasonal_periods
|
||||
).fit()
|
||||
return model.forecast(hours)
|
||||
|
||||
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
return np.full(hours, np.median(clean_history))
|
||||
|
||||
def _update_data(
|
||||
self, force_update: Optional[bool] = False
|
||||
) -> None: # tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Update forecast data in the ElecPriceDataRecord format.
|
||||
|
||||
Retrieves data from Energy-Charts, maps each Energy-Charts field to the corresponding
|
||||
`ElecPriceDataRecord` and applies any necessary scaling.
|
||||
|
||||
The final mapped and processed data is inserted into the sequence as `ElecPriceDataRecord`.
|
||||
"""
|
||||
# New prices are available every day at 14:00
|
||||
now = pd.Timestamp.now(tz=self.config.general.timezone)
|
||||
midnight = now.normalize()
|
||||
hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47
|
||||
end = midnight + pd.Timedelta(hours=hours_ahead)
|
||||
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
|
||||
# Determine if update is needed and how many days
|
||||
past_days = 35
|
||||
if self.highest_orig_datetime:
|
||||
history_series = self.key_to_series(
|
||||
key="elecprice_marketprice_wh", start_datetime=self.start_datetime
|
||||
)
|
||||
# If history lower, then start_datetime
|
||||
if history_series.index.min() <= self.start_datetime:
|
||||
past_days = 0
|
||||
|
||||
needs_update = end > self.highest_orig_datetime
|
||||
else:
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
logger.info(
|
||||
f"Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
|
||||
)
|
||||
# Set start_date try to take data from 5 weeks back for prediction
|
||||
start_date = to_datetime(
|
||||
self.start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD"
|
||||
)
|
||||
# Get Energy-Charts electricity price data
|
||||
energy_charts_data = self._request_forecast(
|
||||
start_date=start_date, force_update=force_update
|
||||
) # type: ignore
|
||||
|
||||
# Parse and store data
|
||||
series_data = self._parse_data(energy_charts_data)
|
||||
self.highest_orig_datetime = series_data.index.max()
|
||||
self.key_from_series("elecprice_marketprice_wh", series_data)
|
||||
else:
|
||||
logger.info(
|
||||
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
|
||||
)
|
||||
|
||||
# Generate history array for prediction
|
||||
history = self.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
end_datetime=self.highest_orig_datetime,
|
||||
fill_method="linear",
|
||||
)
|
||||
|
||||
amount_datasets = len(self.records)
|
||||
if not self.highest_orig_datetime: # mypy fix
|
||||
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
|
||||
needed_hours = int(
|
||||
self.config.prediction.hours
|
||||
- ((self.highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
|
||||
)
|
||||
|
||||
if needed_hours <= 0:
|
||||
logger.warning(
|
||||
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.start_datetime}"
|
||||
) # this might keep data longer than self.start_datetime + self.config.prediction.hours in the records
|
||||
return
|
||||
|
||||
if amount_datasets > 800: # we do the full ets with seasons of 1 week
|
||||
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
|
||||
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
|
||||
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
|
||||
elif amount_datasets > 0: # not enough data for ets, do median
|
||||
prediction = self._predict_median(history, hours=needed_hours)
|
||||
else:
|
||||
logger.error("No data available for prediction")
|
||||
raise ValueError("No data available")
|
||||
|
||||
# write predictions into the records, update if exist.
|
||||
prediction_series = pd.Series(
|
||||
data=prediction,
|
||||
index=[
|
||||
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
|
||||
for i in range(len(prediction))
|
||||
],
|
||||
)
|
||||
self.key_from_series("elecprice_marketprice_wh", prediction_series)
|
@@ -9,15 +9,13 @@ format, enabling consistent access to forecasted and historical elecprice attrib
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ElecPriceImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for elecprice data import from file or JSON String."""
|
||||
|
@@ -14,7 +14,7 @@ class SelfConsumptionProbabilityInterpolator:
|
||||
self.filepath = filepath
|
||||
# Load the RegularGridInterpolator
|
||||
with open(self.filepath, "rb") as file:
|
||||
self.interpolator: RegularGridInterpolator = pickle.load(file)
|
||||
self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def generate_points(
|
||||
|
@@ -2,14 +2,23 @@
|
||||
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||
from akkudoktoreos.prediction.loadvrm import LoadVrmCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
|
||||
logger = get_logger(__name__)
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
# Valid load providers
|
||||
load_providers = [
|
||||
provider.provider_id()
|
||||
for provider in prediction_eos.providers
|
||||
if isinstance(provider, LoadProvider)
|
||||
]
|
||||
|
||||
|
||||
class LoadCommonSettings(SettingsBaseModel):
|
||||
@@ -21,6 +30,14 @@ class LoadCommonSettings(SettingsBaseModel):
|
||||
examples=["LoadAkkudoktor"],
|
||||
)
|
||||
|
||||
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
|
||||
Field(default=None, description="Provider settings", examples=[None])
|
||||
)
|
||||
provider_settings: Optional[
|
||||
Union[LoadAkkudoktorCommonSettings, LoadVrmCommonSettings, LoadImportCommonSettings]
|
||||
] = Field(default=None, description="Provider settings", examples=[None])
|
||||
|
||||
# Validators
|
||||
@field_validator("provider", mode="after")
|
||||
@classmethod
|
||||
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None or value in load_providers:
|
||||
return value
|
||||
raise ValueError(f"Provider '{value}' is not a valid load provider: {load_providers}.")
|
||||
|
@@ -9,11 +9,8 @@ from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoadDataRecord(PredictionRecord):
|
||||
"""Represents a load data record containing various load attributes at a specific datetime."""
|
||||
|
@@ -3,15 +3,13 @@
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoadAkkudoktorCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for load data import from file."""
|
||||
@@ -122,10 +120,11 @@ class LoadAkkudoktor(LoadProvider):
|
||||
}
|
||||
if date.day_of_week < 5:
|
||||
# Monday to Friday (0..4)
|
||||
values["load_mean_adjusted"] = hourly_stats[0] + weekday_adjust[date.hour]
|
||||
value_adjusted = hourly_stats[0] + weekday_adjust[date.hour]
|
||||
else:
|
||||
# Saturday, Sunday (5, 6)
|
||||
values["load_mean_adjusted"] = hourly_stats[0] + weekend_adjust[date.hour]
|
||||
value_adjusted = hourly_stats[0] + weekend_adjust[date.hour]
|
||||
values["load_mean_adjusted"] = max(0, value_adjusted)
|
||||
self.update_value(date, values)
|
||||
date += to_duration("1 hour")
|
||||
# We are working on fresh data (no cache), report update time
|
||||
|
@@ -9,15 +9,13 @@ format, enabling consistent access to forecasted and historical load attributes.
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoadImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for load data import from file or JSON string."""
|
||||
|
109
src/akkudoktoreos/prediction/loadvrm.py
Normal file
109
src/akkudoktoreos/prediction/loadvrm.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Retrieves load forecast data from VRM API."""
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import Field, ValidationError
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
class VrmForecastRecords(PydanticBaseModel):
|
||||
vrm_consumption_fc: list[tuple[int, float]]
|
||||
solar_yield_forecast: list[tuple[int, float]]
|
||||
|
||||
|
||||
class VrmForecastResponse(PydanticBaseModel):
|
||||
success: bool
|
||||
records: VrmForecastRecords
|
||||
totals: dict
|
||||
|
||||
|
||||
class LoadVrmCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for VRM API."""
|
||||
|
||||
load_vrm_token: str = Field(
|
||||
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
|
||||
)
|
||||
load_vrm_idsite: int = Field(default=12345, description="VRM-Installation-ID", examples=[12345])
|
||||
|
||||
|
||||
class LoadVrm(LoadProvider):
|
||||
"""Fetch Load forecast data from VRM API."""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
return "LoadVrm"
|
||||
|
||||
@classmethod
|
||||
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
|
||||
"""Validate the VRM API load forecast response."""
|
||||
try:
|
||||
return VrmForecastResponse.model_validate_json(json_str)
|
||||
except ValidationError as e:
|
||||
error_msg = "\n".join(
|
||||
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
|
||||
f"Error: {err['msg']}\nType: {err['type']}"
|
||||
for err in e.errors()
|
||||
)
|
||||
logger.error(f"VRM-API schema validation failed:\n{error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
|
||||
"""Fetch forecast data from Victron VRM API."""
|
||||
base_url = "https://vrmapi.victronenergy.com/v2/installations"
|
||||
installation_id = self.config.load.provider_settings.load_vrm_idsite
|
||||
api_token = self.config.load.provider_settings.load_vrm_token
|
||||
|
||||
url = f"{base_url}/{installation_id}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
|
||||
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
|
||||
|
||||
logger.debug(f"Requesting VRM load forecast: {url}")
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error during VRM API request: {e}")
|
||||
raise RuntimeError("Failed to fetch load forecast from VRM API") from e
|
||||
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return self._validate_data(response.content)
|
||||
|
||||
def _ts_to_datetime(self, timestamp: int) -> DateTime:
|
||||
"""Convert UNIX ms timestamp to timezone-aware datetime."""
|
||||
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Fetch and store VRM load forecast as load_mean and related values."""
|
||||
start_date = self.start_datetime.start_of("day")
|
||||
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
|
||||
start_ts = int(start_date.timestamp())
|
||||
end_ts = int(end_date.timestamp())
|
||||
|
||||
logger.info(f"Updating Load forecast from VRM: {start_date} to {end_date}")
|
||||
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
|
||||
|
||||
load_mean_data = []
|
||||
for timestamp, value in vrm_forecast_data.records.vrm_consumption_fc:
|
||||
date = self._ts_to_datetime(timestamp)
|
||||
rounded_value = round(value, 2)
|
||||
|
||||
self.update_value(
|
||||
date,
|
||||
{"load_mean": rounded_value, "load_std": 0.0, "load_mean_adjusted": rounded_value},
|
||||
)
|
||||
|
||||
load_mean_data.append((date, rounded_value))
|
||||
|
||||
logger.debug(f"Updated load_mean with {len(load_mean_data)} entries.")
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
lv = LoadVrm()
|
||||
lv._update_data()
|
@@ -32,12 +32,15 @@ from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
|
||||
from akkudoktoreos.prediction.loadimport import LoadImport
|
||||
from akkudoktoreos.prediction.loadvrm import LoadVrm
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionContainer
|
||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
|
||||
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
|
||||
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
|
||||
from akkudoktoreos.prediction.weatherimport import WeatherImport
|
||||
@@ -83,10 +86,13 @@ class Prediction(PredictionContainer):
|
||||
providers: List[
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceImport,
|
||||
LoadAkkudoktor,
|
||||
LoadVrm,
|
||||
LoadImport,
|
||||
PVForecastAkkudoktor,
|
||||
PVForecastVrm,
|
||||
PVForecastImport,
|
||||
WeatherBrightSky,
|
||||
WeatherClearOutside,
|
||||
@@ -97,10 +103,13 @@ class Prediction(PredictionContainer):
|
||||
|
||||
# Initialize forecast providers, all are singletons.
|
||||
elecprice_akkudoktor = ElecPriceAkkudoktor()
|
||||
elecprice_energy_charts = ElecPriceEnergyCharts()
|
||||
elecprice_import = ElecPriceImport()
|
||||
load_akkudoktor = LoadAkkudoktor()
|
||||
load_vrm = LoadVrm()
|
||||
load_import = LoadImport()
|
||||
pvforecast_akkudoktor = PVForecastAkkudoktor()
|
||||
pvforecast_vrm = PVForecastVrm()
|
||||
pvforecast_import = PVForecastImport()
|
||||
weather_brightsky = WeatherBrightSky()
|
||||
weather_clearoutside = WeatherClearOutside()
|
||||
@@ -114,10 +123,13 @@ def get_prediction() -> Prediction:
|
||||
prediction = Prediction(
|
||||
providers=[
|
||||
elecprice_akkudoktor,
|
||||
elecprice_energy_charts,
|
||||
elecprice_import,
|
||||
load_akkudoktor,
|
||||
load_vrm,
|
||||
load_import,
|
||||
pvforecast_akkudoktor,
|
||||
pvforecast_vrm,
|
||||
pvforecast_import,
|
||||
weather_brightsky,
|
||||
weather_clearoutside,
|
||||
|
@@ -10,6 +10,7 @@ and manipulation of configuration and prediction data in a clear, scalable, and
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
@@ -22,11 +23,8 @@ from akkudoktoreos.core.dataabc import (
|
||||
DataRecord,
|
||||
DataSequence,
|
||||
)
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PredictionBase(DataBase, MeasurementMixin):
|
||||
"""Base class for handling prediction data.
|
||||
|
@@ -1,15 +1,24 @@
|
||||
"""PV forecast module for PV power predictions."""
|
||||
|
||||
from typing import Any, ClassVar, List, Optional, Self
|
||||
from typing import Any, List, Optional, Self, Union
|
||||
|
||||
from pydantic import Field, computed_field, field_validator, model_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecastvrm import PVforecastVrmCommonSettings
|
||||
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
||||
|
||||
logger = get_logger(__name__)
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
# Valid PV forecast providers
|
||||
pvforecast_providers = [
|
||||
provider.provider_id()
|
||||
for provider in prediction_eos.providers
|
||||
if isinstance(provider, PVForecastProvider)
|
||||
]
|
||||
|
||||
|
||||
class PVForecastPlaneSetting(SettingsBaseModel):
|
||||
@@ -17,14 +26,18 @@ class PVForecastPlaneSetting(SettingsBaseModel):
|
||||
|
||||
# latitude: Optional[float] = Field(default=None, description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)")
|
||||
surface_tilt: Optional[float] = Field(
|
||||
default=None,
|
||||
default=30.0,
|
||||
ge=0.0,
|
||||
le=90.0,
|
||||
description="Tilt angle from horizontal plane. Ignored for two-axis tracking.",
|
||||
examples=[10.0, 20.0],
|
||||
)
|
||||
surface_azimuth: Optional[float] = Field(
|
||||
default=None,
|
||||
default=180.0,
|
||||
ge=0.0,
|
||||
le=360.0,
|
||||
description="Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270).",
|
||||
examples=[10.0, 20.0],
|
||||
examples=[180.0, 90.0],
|
||||
)
|
||||
userhorizon: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
@@ -71,7 +84,7 @@ class PVForecastPlaneSetting(SettingsBaseModel):
|
||||
default=None, description="Model of the inverter of this plane.", examples=[None]
|
||||
)
|
||||
inverter_paco: Optional[int] = Field(
|
||||
default=None, description="AC power rating of the inverter. [W]", examples=[6000, 4000]
|
||||
default=None, description="AC power rating of the inverter [W].", examples=[6000, 4000]
|
||||
)
|
||||
modules_per_string: Optional[int] = Field(
|
||||
default=None,
|
||||
@@ -122,26 +135,32 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
||||
examples=["PVForecastAkkudoktor"],
|
||||
)
|
||||
|
||||
provider_settings: Optional[
|
||||
Union[PVForecastImportCommonSettings, PVforecastVrmCommonSettings]
|
||||
] = Field(default=None, description="Provider settings", examples=[None])
|
||||
|
||||
planes: Optional[list[PVForecastPlaneSetting]] = Field(
|
||||
default=None,
|
||||
description="Plane configuration.",
|
||||
examples=[get_model_structure_from_examples(PVForecastPlaneSetting, True)],
|
||||
)
|
||||
|
||||
max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set
|
||||
|
||||
@field_validator("planes")
|
||||
def validate_planes(
|
||||
cls, planes: Optional[list[PVForecastPlaneSetting]]
|
||||
) -> Optional[list[PVForecastPlaneSetting]]:
|
||||
if planes is not None and len(planes) > cls.max_planes:
|
||||
raise ValueError(f"Maximum number of supported planes: {cls.max_planes}.")
|
||||
return planes
|
||||
|
||||
provider_settings: Optional[PVForecastImportCommonSettings] = Field(
|
||||
default=None, description="Provider settings", examples=[None]
|
||||
max_planes: Optional[int] = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Maximum number of planes that can be set",
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("provider", mode="after")
|
||||
@classmethod
|
||||
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None or value in pvforecast_providers:
|
||||
return value
|
||||
raise ValueError(
|
||||
f"Provider '{value}' is not a valid PV forecast provider: {pvforecast_providers}."
|
||||
)
|
||||
|
||||
## Computed fields
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
|
@@ -7,13 +7,11 @@ Notes:
|
||||
from abc import abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PVForecastDataRecord(PredictionRecord):
|
||||
"""Represents a pvforecast data record containing various pvforecast attributes at a specific datetime."""
|
||||
|
@@ -27,14 +27,14 @@ Example:
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_azimuth": 170,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_azimuth": 90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
@@ -78,10 +78,10 @@ Methods:
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import requests
|
||||
from pydantic import Field, ValidationError, computed_field
|
||||
from loguru import logger
|
||||
from pydantic import Field, ValidationError, computed_field, field_validator
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.pvforecastabc import (
|
||||
PVForecastDataRecord,
|
||||
@@ -89,13 +89,11 @@ from akkudoktoreos.prediction.pvforecastabc import (
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AkkudoktorForecastHorizon(PydanticBaseModel):
|
||||
altitude: int
|
||||
azimuthFrom: int
|
||||
azimuthTo: int
|
||||
azimuthFrom: float
|
||||
azimuthTo: float
|
||||
|
||||
|
||||
class AkkudoktorForecastMeta(PydanticBaseModel):
|
||||
@@ -114,6 +112,30 @@ class AkkudoktorForecastMeta(PydanticBaseModel):
|
||||
horizont: List[List[AkkudoktorForecastHorizon]]
|
||||
horizontString: List[str]
|
||||
|
||||
@field_validator("power", "azimuth", "tilt", "powerInverter", mode="before")
|
||||
@classmethod
|
||||
def ensure_list(cls, v: Any) -> List[int]:
|
||||
return v if isinstance(v, list) else [v]
|
||||
|
||||
@field_validator("horizont", mode="before")
|
||||
@classmethod
|
||||
def normalize_horizont(cls, v: Any) -> List[List[AkkudoktorForecastHorizon]]:
|
||||
if isinstance(v, list):
|
||||
# Case: flat list of dicts
|
||||
if v and isinstance(v[0], dict):
|
||||
return [v]
|
||||
# Already in correct nested form
|
||||
if v and isinstance(v[0], list):
|
||||
return v
|
||||
return v
|
||||
|
||||
@field_validator("horizontString", mode="before")
|
||||
@classmethod
|
||||
def parse_horizont_string(cls, v: Any) -> List[str]:
|
||||
if isinstance(v, str):
|
||||
return [s.strip() for s in v.split(",")]
|
||||
return v
|
||||
|
||||
|
||||
class AkkudoktorForecastValue(PydanticBaseModel):
|
||||
datetime: str
|
||||
@@ -221,13 +243,18 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
|
||||
for i in range(len(self.config.pvforecast.planes)):
|
||||
query_params.append(f"power={int(self.config.pvforecast.planes_peakpower[i] * 1000)}")
|
||||
query_params.append(f"azimuth={int(self.config.pvforecast.planes_azimuth[i])}")
|
||||
# EOS orientation of of pv modules in azimuth in degree:
|
||||
# north=0, east=90, south=180, west=270
|
||||
# Akkudoktor orientation of pv modules in azimuth in degree:
|
||||
# north=+-180, east=-90, south=0, west=90
|
||||
azimuth_akkudoktor = int(self.config.pvforecast.planes_azimuth[i]) - 180
|
||||
query_params.append(f"azimuth={azimuth_akkudoktor}")
|
||||
query_params.append(f"tilt={int(self.config.pvforecast.planes_tilt[i])}")
|
||||
query_params.append(
|
||||
f"powerInverter={int(self.config.pvforecast.planes_inverter_paco[i])}"
|
||||
)
|
||||
horizon_values = ",".join(
|
||||
str(int(h)) for h in self.config.pvforecast.planes_userhorizon[i]
|
||||
str(round(h)) for h in self.config.pvforecast.planes_userhorizon[i]
|
||||
)
|
||||
query_params.append(f"horizont={horizon_values}")
|
||||
|
||||
@@ -262,7 +289,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
Raises:
|
||||
ValueError: If the API response does not include expected `meta` data.
|
||||
"""
|
||||
response = requests.get(self._url())
|
||||
response = requests.get(self._url(), timeout=10)
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
logger.debug(f"Response from {self._url()}: {response}")
|
||||
akkudoktor_data = self._validate_data(response.content)
|
||||
@@ -303,7 +330,8 @@ class PVForecastAkkudoktor(PVForecastProvider):
|
||||
logger.error(f"Akkudoktor schema change: {error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
assert self.start_datetime # mypy fix
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
|
||||
# Iterate over forecast data points
|
||||
for forecast_values in zip(*akkudoktor_data.values):
|
||||
@@ -390,28 +418,28 @@ if __name__ == "__main__":
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_azimuth": 170,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_azimuth": 90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"inverter_paco": 10000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_azimuth": 140,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"inverter_paco": 2000,
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_azimuth": 185,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"inverter_paco": 1400,
|
||||
|
@@ -9,15 +9,13 @@ format, enabling consistent access to forecasted and historical pvforecast attri
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
|
||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PVForecastImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for pvforecast data import from file or JSON string."""
|
||||
|
110
src/akkudoktoreos/prediction/pvforecastvrm.py
Normal file
110
src/akkudoktoreos/prediction/pvforecastvrm.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Retrieves pvforecast data from VRM API."""
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pendulum import DateTime
|
||||
from pydantic import Field, ValidationError
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
class VrmForecastRecords(PydanticBaseModel):
|
||||
vrm_consumption_fc: list[tuple[int, float]]
|
||||
solar_yield_forecast: list[tuple[int, float]]
|
||||
|
||||
|
||||
class VrmForecastResponse(PydanticBaseModel):
|
||||
success: bool
|
||||
records: VrmForecastRecords
|
||||
totals: dict
|
||||
|
||||
|
||||
class PVforecastVrmCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for VRM API."""
|
||||
|
||||
pvforecast_vrm_token: str = Field(
|
||||
default="your-token", description="Token for Connecting VRM API", examples=["your-token"]
|
||||
)
|
||||
pvforecast_vrm_idsite: int = Field(
|
||||
default=12345, description="VRM-Installation-ID", examples=[12345]
|
||||
)
|
||||
|
||||
|
||||
class PVForecastVrm(PVForecastProvider):
|
||||
"""Fetch and process PV forecast data from VRM API."""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the PV-Forecast-Provider."""
|
||||
return "PVForecastVrm"
|
||||
|
||||
@classmethod
|
||||
def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse:
|
||||
"""Validate the VRM forecast response data against the expected schema."""
|
||||
try:
|
||||
return VrmForecastResponse.model_validate_json(json_str)
|
||||
except ValidationError as e:
|
||||
error_msg = "\n".join(
|
||||
f"Field: {' -> '.join(str(x) for x in err['loc'])}\n"
|
||||
f"Error: {err['msg']}\nType: {err['type']}"
|
||||
for err in e.errors()
|
||||
)
|
||||
logger.error(f"VRM-API schema change:\n{error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse:
|
||||
"""Fetch forecast data from Victron VRM API."""
|
||||
source = "https://vrmapi.victronenergy.com/v2/installations"
|
||||
id_site = self.config.pvforecast.provider_settings.pvforecast_vrm_idsite
|
||||
api_token = self.config.pvforecast.provider_settings.pvforecast_vrm_token
|
||||
headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"}
|
||||
url = f"{source}/{id_site}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours"
|
||||
logger.debug(f"Requesting VRM forecast: {url}")
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch pvforecast: {e}")
|
||||
raise RuntimeError("Failed to fetch pvforecast from VRM API") from e
|
||||
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return self._validate_data(response.content)
|
||||
|
||||
def _ts_to_datetime(self, timestamp: int) -> DateTime:
|
||||
"""Convert UNIX ms timestamp to timezone-aware datetime."""
|
||||
return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone)
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Update forecast data in the PVForecastDataRecord format."""
|
||||
start_date = self.start_datetime.start_of("day")
|
||||
end_date = self.start_datetime.add(hours=self.config.prediction.hours)
|
||||
start_ts = int(start_date.timestamp())
|
||||
end_ts = int(end_date.timestamp())
|
||||
|
||||
logger.info(f"Updating PV forecast from VRM: {start_date} to {end_date}")
|
||||
vrm_forecast_data = self._request_forecast(start_ts, end_ts)
|
||||
|
||||
pv_forecast = []
|
||||
for timestamp, value in vrm_forecast_data.records.solar_yield_forecast:
|
||||
date = self._ts_to_datetime(timestamp)
|
||||
dc_power = round(value, 2)
|
||||
ac_power = round(dc_power * 0.96, 2)
|
||||
self.update_value(
|
||||
date, {"pvforecast_dc_power": dc_power, "pvforecast_ac_power": ac_power}
|
||||
)
|
||||
pv_forecast.append((date, dc_power))
|
||||
|
||||
logger.debug(f"Updated pvforecast_dc_power with {len(pv_forecast)} entries.")
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
pv = PVForecastVrm()
|
||||
pv._update_data()
|
@@ -2,11 +2,22 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
from akkudoktoreos.prediction.weatherabc import WeatherProvider
|
||||
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
||||
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
# Valid weather providers
|
||||
weather_providers = [
|
||||
provider.provider_id()
|
||||
for provider in prediction_eos.providers
|
||||
if isinstance(provider, WeatherProvider)
|
||||
]
|
||||
|
||||
|
||||
class WeatherCommonSettings(SettingsBaseModel):
|
||||
"""Weather Forecast Configuration."""
|
||||
@@ -20,3 +31,13 @@ class WeatherCommonSettings(SettingsBaseModel):
|
||||
provider_settings: Optional[WeatherImportCommonSettings] = Field(
|
||||
default=None, description="Provider settings", examples=[None]
|
||||
)
|
||||
|
||||
# Validators
|
||||
@field_validator("provider", mode="after")
|
||||
@classmethod
|
||||
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None or value in weather_providers:
|
||||
return value
|
||||
raise ValueError(
|
||||
f"Provider '{value}' is not a valid weather provider: {weather_providers}."
|
||||
)
|
||||
|
@@ -14,11 +14,8 @@ import pandas as pd
|
||||
import pvlib
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WeatherDataRecord(PredictionRecord):
|
||||
"""Represents a weather data record containing various weather attributes at a specific datetime.
|
||||
|
@@ -7,23 +7,21 @@ format, enabling consistent access to forecasted and historical weather attribut
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pvlib
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
WheaterDataBrightSkyMapping: List[Tuple[str, Optional[str], Optional[float]]] = [
|
||||
WheaterDataBrightSkyMapping: List[Tuple[str, Optional[str], Optional[Union[str, float]]]] = [
|
||||
# brightsky_key, description, corr_factor
|
||||
("timestamp", "DateTime", None),
|
||||
("timestamp", "DateTime", "to datetime in timezone"),
|
||||
("precipitation", "Precipitation Amount (mm)", 1),
|
||||
("pressure_msl", "Pressure (mb)", 1),
|
||||
("sunshine", None, None),
|
||||
@@ -96,10 +94,11 @@ class WeatherBrightSky(WeatherProvider):
|
||||
ValueError: If the API response does not include expected `weather` data.
|
||||
"""
|
||||
source = "https://api.brightsky.dev"
|
||||
date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD")
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
date = to_datetime(self.start_datetime, as_string=True)
|
||||
last_date = to_datetime(self.end_datetime, as_string=True)
|
||||
response = requests.get(
|
||||
f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}"
|
||||
f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}",
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
logger.debug(f"Response from {source}: {response}")
|
||||
@@ -133,7 +132,8 @@ class WeatherBrightSky(WeatherProvider):
|
||||
error_msg = f"No WeatherDataRecord key for '{description}'"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
return self.key_to_series(key)
|
||||
series = self.key_to_series(key)
|
||||
return series
|
||||
|
||||
def _description_from_series(self, description: str, data: pd.Series) -> None:
|
||||
"""Update a weather data with a pandas Series based on its description.
|
||||
@@ -170,7 +170,7 @@ class WeatherBrightSky(WeatherProvider):
|
||||
brightsky_data = self._request_forecast(force_update=force_update) # type: ignore
|
||||
|
||||
# Get key mapping from description
|
||||
brightsky_key_mapping: Dict[str, Tuple[Optional[str], Optional[float]]] = {}
|
||||
brightsky_key_mapping: Dict[str, Tuple[Optional[str], Optional[Union[str, float]]]] = {}
|
||||
for brightsky_key, description, corr_factor in WheaterDataBrightSkyMapping:
|
||||
if description is None:
|
||||
brightsky_key_mapping[brightsky_key] = (None, None)
|
||||
@@ -192,7 +192,10 @@ class WeatherBrightSky(WeatherProvider):
|
||||
value = brightsky_record[brightsky_key]
|
||||
corr_factor = item[1]
|
||||
if value and corr_factor:
|
||||
value = value * corr_factor
|
||||
if corr_factor == "to datetime in timezone":
|
||||
value = to_datetime(value, in_timezone=self.config.general.timezone)
|
||||
else:
|
||||
value = value * corr_factor
|
||||
setattr(weather_record, key, value)
|
||||
self.insert_by_datetime(weather_record)
|
||||
|
||||
@@ -216,14 +219,40 @@ class WeatherBrightSky(WeatherProvider):
|
||||
self._description_from_series(description, dhi)
|
||||
|
||||
# Add Preciptable Water (PWAT) with a PVLib method.
|
||||
description = "Temperature (°C)"
|
||||
temperature = self._description_to_series(description)
|
||||
|
||||
description = "Relative Humidity (%)"
|
||||
humidity = self._description_to_series(description)
|
||||
|
||||
key = WeatherDataRecord.key_from_description("Temperature (°C)")
|
||||
assert key # noqa: S101
|
||||
temperature = self.key_to_array(
|
||||
key=key,
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
if any(x is None or isinstance(x, float) and np.isnan(x) for x in temperature):
|
||||
# We can not calculate PWAT
|
||||
debug_msg = f"Innvalid temperature '{temperature}'"
|
||||
logger.debug(debug_msg)
|
||||
return
|
||||
key = WeatherDataRecord.key_from_description("Relative Humidity (%)")
|
||||
assert key # noqa: S101
|
||||
humidity = self.key_to_array(
|
||||
key=key,
|
||||
start_datetime=self.start_datetime,
|
||||
end_datetime=self.end_datetime,
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
if any(x is None or isinstance(x, float) and np.isnan(x) for x in humidity):
|
||||
# We can not calculate PWAT
|
||||
debug_msg = f"Innvalid humidity '{humidity}'"
|
||||
logger.debug(debug_msg)
|
||||
return
|
||||
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
|
||||
pwat = pd.Series(
|
||||
data=pvlib.atmosphere.gueymard94_pw(temperature, humidity), index=temperature.index
|
||||
data=data,
|
||||
index=pd.DatetimeIndex(
|
||||
pd.date_range(
|
||||
start=self.start_datetime, end=self.end_datetime, freq="1h", inclusive="left"
|
||||
)
|
||||
),
|
||||
)
|
||||
description = "Preciptable Water (cm)"
|
||||
self._description_from_series(description, pwat)
|
||||
|
@@ -18,15 +18,12 @@ from typing import Dict, List, Optional, Tuple
|
||||
import pandas as pd
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_timezone
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
WheaterDataClearOutsideMapping: List[Tuple[str, Optional[str], Optional[float]]] = [
|
||||
# clearoutside_key, description, corr_factor
|
||||
("DateTime", "DateTime", None),
|
||||
@@ -88,12 +85,12 @@ class WeatherClearOutside(WeatherProvider):
|
||||
"""Requests weather forecast from ClearOutside.
|
||||
|
||||
Returns:
|
||||
response: Weather forecast request reponse from ClearOutside.
|
||||
response: Weather forecast request response from ClearOutside.
|
||||
"""
|
||||
source = "https://clearoutside.com/forecast"
|
||||
latitude = round(self.config.general.latitude, 2)
|
||||
longitude = round(self.config.general.longitude, 2)
|
||||
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
|
||||
response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true", timeout=10)
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
logger.debug(f"Response from {source}: {response}")
|
||||
# We are working on fresh data (no cache), report update time
|
||||
|
@@ -9,15 +9,13 @@ format, enabling consistent access to forecasted and historical weather attribut
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
|
||||
from akkudoktoreos.prediction.weatherabc import WeatherProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WeatherImportCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for weather data import from file or JSON string."""
|
||||
|
297
src/akkudoktoreos/server/dash/admin.py
Normal file
297
src/akkudoktoreos/server/dash/admin.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Admin UI components for EOS Dashboard.
|
||||
|
||||
This module provides functions to generate administrative UI components
|
||||
for the EOS dashboard.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from fasthtml.common import Select
|
||||
from loguru import logger
|
||||
from monsterui.foundations import stringify
|
||||
from monsterui.franken import ( # Select, TODO: Select from FrankenUI does not work - using Select from FastHTML instead
|
||||
H3,
|
||||
Button,
|
||||
ButtonT,
|
||||
Card,
|
||||
Details,
|
||||
Div,
|
||||
DivHStacked,
|
||||
DividerLine,
|
||||
Grid,
|
||||
Input,
|
||||
Options,
|
||||
P,
|
||||
Summary,
|
||||
UkIcon,
|
||||
)
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from akkudoktoreos.server.dash.components import Error, Success
|
||||
from akkudoktoreos.server.dash.configuration import get_nested_value
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
# Directory to export files to, or to import files from
|
||||
export_import_directory = Path(user_config_dir("net.akkudoktor.eosdash", "akkudoktor"))
|
||||
|
||||
|
||||
def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
|
||||
"""Creates a styled button for administrative actions.
|
||||
|
||||
Args:
|
||||
*c (Any): Positional arguments representing the button's content.
|
||||
cls (Optional[Union[str, tuple]]): Additional CSS classes for styling. Defaults to None.
|
||||
**kwargs (Any): Additional keyword arguments passed to the `Button`.
|
||||
|
||||
Returns:
|
||||
Button: A styled `Button` component for admin actions.
|
||||
"""
|
||||
new_cls = f"{ButtonT.primary}"
|
||||
if cls:
|
||||
new_cls += f" {stringify(cls)}"
|
||||
kwargs["cls"] = new_cls
|
||||
return Button(*c, submit=False, **kwargs)
|
||||
|
||||
|
||||
def AdminConfig(
|
||||
eos_host: str, eos_port: Union[str, int], data: Optional[dict], config: Optional[dict[str, Any]]
|
||||
) -> tuple[str, Union[Card, list[Card]]]:
|
||||
"""Creates a configuration management card with save-to-file functionality.
|
||||
|
||||
Args:
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
data (Optional[dict]): Incoming data containing action and category for processing.
|
||||
|
||||
Returns:
|
||||
tuple[str, Union[Card, list[Card]]]: A tuple containing the configuration category label and the `Card` UI component.
|
||||
"""
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
eos_hostname = "EOS server"
|
||||
eosdash_hostname = "EOSdash server"
|
||||
|
||||
category = "configuration"
|
||||
# save config file
|
||||
status = (None,)
|
||||
config_file_path = "<unknown>"
|
||||
try:
|
||||
if config:
|
||||
config_file_path = get_nested_value(config, ["general", "config_file_path"])
|
||||
except Exception as e:
|
||||
logger.debug(f"general.config_file_path: {e}")
|
||||
# export config file
|
||||
export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss")
|
||||
export_to_file_status = (None,)
|
||||
# import config file
|
||||
import_from_file_status = (None,)
|
||||
|
||||
if data and data.get("category", None) == category:
|
||||
# This data is for us
|
||||
if data["action"] == "save_to_file":
|
||||
# Safe current configuration to file
|
||||
try:
|
||||
result = requests.put(f"{server}/v1/config/file", timeout=10)
|
||||
result.raise_for_status()
|
||||
config_file_path = result.json()["general"]["config_file_path"]
|
||||
status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
status = Error(
|
||||
f"Can not save actual config to file on '{eos_hostname}': {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
status = Error(f"Can not save actual config to file on '{eos_hostname}': {e}")
|
||||
elif data["action"] == "export_to_file":
|
||||
# Export current configuration to file
|
||||
export_to_file_tag = data.get("export_to_file_tag", export_to_file_next_tag)
|
||||
export_to_file_path = export_import_directory.joinpath(
|
||||
f"eos_config_{export_to_file_tag}.json"
|
||||
)
|
||||
try:
|
||||
if not config:
|
||||
raise ValueError(f"No config from '{eos_hostname}'")
|
||||
export_to_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with export_to_file_path.open("w", encoding="utf-8", newline="\n") as fd:
|
||||
json.dump(config, fd, indent=4, sort_keys=True)
|
||||
export_to_file_status = Success(
|
||||
f"Exported to '{export_to_file_path}' on '{eosdash_hostname}'"
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
export_to_file_status = Error(
|
||||
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
export_to_file_status = Error(
|
||||
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}"
|
||||
)
|
||||
elif data["action"] == "import_from_file":
|
||||
import_file_name = data.get("import_file_name", None)
|
||||
import_from_file_pathes = list(
|
||||
export_import_directory.glob("*.json")
|
||||
) # expand generator object
|
||||
import_file_path = None
|
||||
for f in import_from_file_pathes:
|
||||
if f.name == import_file_name:
|
||||
import_file_path = f
|
||||
if import_file_path:
|
||||
try:
|
||||
with import_file_path.open("r", encoding="utf-8", newline=None) as fd:
|
||||
import_config = json.load(fd)
|
||||
result = requests.put(f"{server}/v1/config", json=import_config, timeout=10)
|
||||
result.raise_for_status()
|
||||
import_from_file_status = Success(
|
||||
f"Config imported from '{import_file_path}' on '{eosdash_hostname}'"
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}"
|
||||
)
|
||||
else:
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}', not found in '{export_import_directory}' on '{eosdash_hostname}'"
|
||||
)
|
||||
|
||||
# Update for display, in case we added a new file before
|
||||
import_from_file_names = [f.name for f in list(export_import_directory.glob("*.json"))]
|
||||
|
||||
return (
|
||||
category,
|
||||
[
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Save to file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='{"category": "configuration", "action": "save_to_file"}',
|
||||
),
|
||||
P(f"'{config_file_path}' on '{eos_hostname}'"),
|
||||
),
|
||||
status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(f"Safe actual configuration to '{config_file_path}' on '{eos_hostname}'."),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Export to file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{"category": "configuration", "action": "export_to_file", "export_to_file_tag": document.querySelector("[name=\'chosen_export_file_tag\']").value }',
|
||||
),
|
||||
P("'eos_config_"),
|
||||
Input(
|
||||
id="export_file_tag",
|
||||
name="chosen_export_file_tag",
|
||||
value=export_to_file_next_tag,
|
||||
),
|
||||
P(".json'"),
|
||||
),
|
||||
export_to_file_status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(
|
||||
f"Export actual configuration to 'eos_config_{export_to_file_next_tag}.json' on '{eosdash_hostname}'."
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Import from file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "configuration", "action": "import_from_file", "import_file_name": document.querySelector("[name=\'selected_import_file_name\']").value }',
|
||||
),
|
||||
Select(
|
||||
*Options(*import_from_file_names),
|
||||
id="import_file_name",
|
||||
name="selected_import_file_name", # Name of hidden input field with selected value
|
||||
placeholder="Select file",
|
||||
),
|
||||
),
|
||||
import_from_file_status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(f"Import configuration from config file on '{eosdash_hostname}'."),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) -> Div:
|
||||
"""Generates the administrative dashboard layout.
|
||||
|
||||
This includes configuration management and other administrative tools.
|
||||
|
||||
Args:
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
data (Optional[dict], optional): Incoming data to trigger admin actions. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Div: A `Div` component containing the assembled admin interface.
|
||||
"""
|
||||
# Get current configuration from server
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config", timeout=10)
|
||||
result.raise_for_status()
|
||||
config = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
config = {}
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
except Exception as e:
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
|
||||
rows = []
|
||||
last_category = ""
|
||||
for category, admin in [
|
||||
AdminConfig(eos_host, eos_port, data, config),
|
||||
]:
|
||||
if category != last_category:
|
||||
rows.append(H3(category))
|
||||
rows.append(DividerLine())
|
||||
last_category = category
|
||||
if isinstance(admin, list):
|
||||
for card in admin:
|
||||
rows.append(card)
|
||||
else:
|
||||
rows.append(admin)
|
||||
|
||||
return Div(*rows, cls="space-y-4")
|
@@ -8,19 +8,19 @@ from bokeh.models import Plot
|
||||
from monsterui.franken import H4, Card, NotStr, Script
|
||||
|
||||
BokehJS = [
|
||||
Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.6.3.min.js", crossorigin="anonymous"),
|
||||
Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.7.0.min.js", crossorigin="anonymous"),
|
||||
Script(
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.6.3.min.js",
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.7.0.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
Script(
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.6.3.min.js", crossorigin="anonymous"
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.7.0.min.js", crossorigin="anonymous"
|
||||
),
|
||||
Script(
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.6.3.min.js", crossorigin="anonymous"
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.7.0.min.js", crossorigin="anonymous"
|
||||
),
|
||||
Script(
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.6.3.min.js",
|
||||
src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.7.0.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
]
|
||||
|
@@ -1,10 +1,13 @@
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from fasthtml.common import H1, Div, Li
|
||||
|
||||
# from mdit_py_plugins import plugin1, plugin2
|
||||
from monsterui.daisy import (
|
||||
Alert,
|
||||
AlertT,
|
||||
)
|
||||
from monsterui.foundations import stringify
|
||||
from monsterui.franken import (
|
||||
H3,
|
||||
Button,
|
||||
ButtonT,
|
||||
Card,
|
||||
@@ -13,6 +16,7 @@ from monsterui.franken import (
|
||||
Details,
|
||||
DivLAligned,
|
||||
DivRAligned,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
P,
|
||||
@@ -69,9 +73,69 @@ def ScrollArea(
|
||||
)
|
||||
|
||||
|
||||
def Success(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("check"),
|
||||
P(*c),
|
||||
),
|
||||
cls=AlertT.success,
|
||||
)
|
||||
|
||||
|
||||
def Error(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("triangle-alert"),
|
||||
P(*c),
|
||||
),
|
||||
cls=AlertT.error,
|
||||
)
|
||||
|
||||
|
||||
def ConfigCard(
|
||||
config_name: str, config_type: str, read_only: str, value: str, default: str, description: str
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
read_only: str,
|
||||
value: str,
|
||||
default: str,
|
||||
description: str,
|
||||
deprecated: Optional[Union[str, bool]],
|
||||
update_error: Optional[str],
|
||||
update_value: Optional[str],
|
||||
update_open: Optional[bool],
|
||||
) -> Card:
|
||||
"""Creates a styled configuration card for displaying configuration details.
|
||||
|
||||
This function generates a configuration card that is displayed in the UI with
|
||||
various sections such as configuration name, type, description, default value,
|
||||
current value, and error details. It supports both read-only and editable modes.
|
||||
|
||||
Args:
|
||||
config_name (str): The name of the configuration.
|
||||
config_type (str): The type of the configuration.
|
||||
read_only (str): Indicates if the configuration is read-only ("rw" for read-write,
|
||||
any other value indicates read-only).
|
||||
value (str): The current value of the configuration.
|
||||
default (str): The default value of the configuration.
|
||||
description (str): A description of the configuration.
|
||||
deprecated (Optional[Union[str, bool]]): The deprecated marker of the configuration.
|
||||
update_error (Optional[str]): The error message, if any, during the update process.
|
||||
update_value (Optional[str]): The value to be updated, if different from the current value.
|
||||
update_open (Optional[bool]): A flag indicating whether the update section of the card
|
||||
should be initially expanded.
|
||||
|
||||
Returns:
|
||||
Card: A styled Card component containing the configuration details.
|
||||
"""
|
||||
config_id = config_name.replace(".", "-")
|
||||
if not update_value:
|
||||
update_value = value
|
||||
if not update_open:
|
||||
update_open = False
|
||||
if deprecated:
|
||||
if isinstance(deprecated, bool):
|
||||
deprecated = "Deprecated"
|
||||
return Card(
|
||||
Details(
|
||||
Summary(
|
||||
@@ -85,24 +149,53 @@ def ConfigCard(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
Input(value=value) if read_only == "rw" else P(value),
|
||||
P(value),
|
||||
),
|
||||
# cls="flex cursor-pointer list-none items-center gap-4",
|
||||
cls="list-none",
|
||||
),
|
||||
Grid(
|
||||
P(description),
|
||||
P(config_type),
|
||||
),
|
||||
Grid(
|
||||
DivRAligned(
|
||||
P("default") if read_only == "rw" else P(""),
|
||||
),
|
||||
P(default) if read_only == "rw" else P(""),
|
||||
)
|
||||
if read_only == "rw"
|
||||
if not deprecated
|
||||
else None,
|
||||
Grid(
|
||||
P(deprecated),
|
||||
P("DEPRECATED!"),
|
||||
)
|
||||
if deprecated
|
||||
else None,
|
||||
# Default
|
||||
Grid(
|
||||
DivRAligned(P("default")),
|
||||
P(default),
|
||||
)
|
||||
if read_only == "rw" and not deprecated
|
||||
else None,
|
||||
# Set value
|
||||
Grid(
|
||||
DivRAligned(P("update")),
|
||||
Grid(
|
||||
Form(
|
||||
Input(value=config_name, type="hidden", id="key"),
|
||||
Input(value=update_value, type="text", id="value"),
|
||||
hx_put="/eosdash/configuration",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
),
|
||||
)
|
||||
if read_only == "rw" and not deprecated
|
||||
else None,
|
||||
# Last error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
P(update_error),
|
||||
)
|
||||
if update_error
|
||||
else None,
|
||||
cls="space-y-4 gap-4",
|
||||
open=update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
@@ -173,7 +266,7 @@ def DashboardTabs(dashboard_items: dict[str, str]) -> Card:
|
||||
dash_items = [
|
||||
Li(
|
||||
DashboardTrigger(
|
||||
menu,
|
||||
H3(menu),
|
||||
hx_get=f"{path}",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
|
@@ -1,20 +1,38 @@
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
||||
|
||||
import requests
|
||||
from monsterui.franken import Div, DividerLine, P, Table, Tbody, Td, Th, Thead, Tr
|
||||
from loguru import logger
|
||||
from monsterui.franken import (
|
||||
H3,
|
||||
H4,
|
||||
Card,
|
||||
Details,
|
||||
Div,
|
||||
DividerLine,
|
||||
DivLAligned,
|
||||
DivRAligned,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
P,
|
||||
Summary,
|
||||
UkIcon,
|
||||
)
|
||||
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
|
||||
from akkudoktoreos.server.dash.components import ConfigCard
|
||||
|
||||
logger = get_logger(__name__)
|
||||
config_eos = get_config()
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Latest configuration update results
|
||||
# Dictionary of config names and associated dictionary with keys "value", "result", "error", "open".
|
||||
config_update_latest: dict[str, dict[str, Optional[Union[str, bool]]]] = {}
|
||||
|
||||
|
||||
def get_nested_value(
|
||||
dictionary: Union[Dict[str, Any], List[Any]],
|
||||
@@ -48,10 +66,10 @@ def get_nested_value(
|
||||
# Traverse the structure
|
||||
current = dictionary
|
||||
for key in keys:
|
||||
if isinstance(current, dict) and isinstance(key, str):
|
||||
current = current[key]
|
||||
elif isinstance(current, list) and isinstance(key, int):
|
||||
current = current[key]
|
||||
if isinstance(current, dict):
|
||||
current = current[str(key)]
|
||||
elif isinstance(current, list):
|
||||
current = current[int(key)]
|
||||
else:
|
||||
raise KeyError(f"Invalid key or index: {key}")
|
||||
return current
|
||||
@@ -101,25 +119,36 @@ def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple
|
||||
return resolved_types
|
||||
|
||||
|
||||
def configuration(values: dict) -> list[dict]:
|
||||
def configuration(
|
||||
model: type[PydanticBaseModel], values: dict, values_prefix: list[str] = []
|
||||
) -> list[dict]:
|
||||
"""Generate configuration details based on provided values and model metadata.
|
||||
|
||||
Args:
|
||||
model (type[PydanticBaseModel]): The Pydantic model to extract configuration from.
|
||||
values (dict): A dictionary containing the current configuration values.
|
||||
values_prefix (list[str]): A list of parent type names that prefixes the model values in the values.
|
||||
|
||||
Returns:
|
||||
List[dict]: A sorted list of configuration details, each represented as a dictionary.
|
||||
list[dict]: A sorted list of configuration details, each represented as a dictionary.
|
||||
"""
|
||||
configs = []
|
||||
inner_types: set[type[PydanticBaseModel]] = set()
|
||||
|
||||
for field_name, field_info in list(config_eos.model_fields.items()) + list(
|
||||
config_eos.model_computed_fields.items()
|
||||
for field_name, field_info in list(model.model_fields.items()) + list(
|
||||
model.model_computed_fields.items()
|
||||
):
|
||||
|
||||
def extract_nested_models(
|
||||
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
|
||||
) -> None:
|
||||
"""Extract nested models from the given subfield information.
|
||||
|
||||
Args:
|
||||
subfield_info (Union[ComputedFieldInfo, FieldInfo]): Field metadata from Pydantic.
|
||||
parent_types (list[str]): A list of parent type names for hierarchical representation.
|
||||
"""
|
||||
nonlocal values, values_prefix
|
||||
regular_field = isinstance(subfield_info, FieldInfo)
|
||||
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
|
||||
|
||||
@@ -135,13 +164,18 @@ def configuration(values: dict) -> list[dict]:
|
||||
if found_basic:
|
||||
continue
|
||||
|
||||
config = {}
|
||||
config["name"] = ".".join(parent_types)
|
||||
config["value"] = str(get_nested_value(values, parent_types, "<unknown>"))
|
||||
config["default"] = str(get_default_value(subfield_info, regular_field))
|
||||
config: dict[str, Optional[Any]] = {}
|
||||
config["name"] = ".".join(values_prefix + parent_types)
|
||||
config["value"] = json.dumps(
|
||||
get_nested_value(values, values_prefix + parent_types, "<unknown>")
|
||||
)
|
||||
config["default"] = json.dumps(get_default_value(subfield_info, regular_field))
|
||||
config["description"] = (
|
||||
subfield_info.description if subfield_info.description else ""
|
||||
)
|
||||
config["deprecated"] = (
|
||||
subfield_info.deprecated if subfield_info.deprecated else None
|
||||
)
|
||||
if isinstance(subfield_info, ComputedFieldInfo):
|
||||
config["read-only"] = "ro"
|
||||
type_description = str(subfield_info.return_type)
|
||||
@@ -151,8 +185,8 @@ def configuration(values: dict) -> list[dict]:
|
||||
config["type"] = (
|
||||
type_description.replace("typing.", "")
|
||||
.replace("pathlib.", "")
|
||||
.replace("[", "[ ")
|
||||
.replace("NoneType", "None")
|
||||
.replace("<class 'float'>", "float")
|
||||
)
|
||||
configs.append(config)
|
||||
found_basic = True
|
||||
@@ -171,105 +205,345 @@ def configuration(values: dict) -> list[dict]:
|
||||
return sorted(configs, key=lambda x: x["name"])
|
||||
|
||||
|
||||
def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> list[dict]:
|
||||
def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]:
|
||||
"""Fetch and process configuration data from the specified EOS server.
|
||||
|
||||
Args:
|
||||
eos_host (Optional[str]): The hostname of the server.
|
||||
eos_port (Optional[Union[str, int]]): The port of the server.
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
|
||||
Returns:
|
||||
List[dict]: A list of processed configuration entries.
|
||||
"""
|
||||
if eos_host is None:
|
||||
eos_host = config_eos.server.host
|
||||
if eos_port is None:
|
||||
eos_port = config_eos.server.port
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
|
||||
# Get current configuration from server
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config")
|
||||
result = requests.get(f"{server}/v1/config", timeout=10)
|
||||
result.raise_for_status()
|
||||
config = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
config = {}
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
return configuration({})
|
||||
config = result.json()
|
||||
|
||||
return configuration(config)
|
||||
return configuration(ConfigEOS, config)
|
||||
|
||||
|
||||
def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Div:
|
||||
def ConfigPlanesCard(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
read_only: str,
|
||||
value: str,
|
||||
default: str,
|
||||
description: str,
|
||||
max_planes: int,
|
||||
update_error: Optional[str],
|
||||
update_value: Optional[str],
|
||||
update_open: Optional[bool],
|
||||
) -> Card:
|
||||
"""Creates a styled configuration card for PV planes.
|
||||
|
||||
This function generates a configuration card that is displayed in the UI with
|
||||
various sections such as configuration name, type, description, default value,
|
||||
current value, and error details. It supports both read-only and editable modes.
|
||||
|
||||
Args:
|
||||
config_name (str): The name of the PV planes configuration.
|
||||
config_type (str): The type of the PV planes configuration.
|
||||
read_only (str): Indicates if the PV planes configuration is read-only ("rw" for read-write,
|
||||
any other value indicates read-only).
|
||||
value (str): The current value of the PV planes configuration.
|
||||
default (str): The default value of the PV planes configuration.
|
||||
description (str): A description of the PV planes configuration.
|
||||
max_planes (int): Maximum number of planes that can be set
|
||||
update_error (Optional[str]): The error message, if any, during the update process.
|
||||
update_value (Optional[str]): The value to be updated, if different from the current value.
|
||||
update_open (Optional[bool]): A flag indicating whether the update section of the card
|
||||
should be initially expanded.
|
||||
|
||||
Returns:
|
||||
Card: A styled Card component containing the PV planes configuration details.
|
||||
"""
|
||||
config_id = config_name.replace(".", "-")
|
||||
# Remember overall planes update status
|
||||
planes_update_error = update_error
|
||||
planes_update_value = update_value
|
||||
if not planes_update_value:
|
||||
planes_update_value = value
|
||||
planes_update_open = update_open
|
||||
if not planes_update_open:
|
||||
planes_update_open = False
|
||||
# Create EOS planes configuration
|
||||
eos_planes = json.loads(value)
|
||||
eos_planes_config = {
|
||||
"pvforecast": {
|
||||
"planes": eos_planes,
|
||||
},
|
||||
}
|
||||
# Create cards for all planes
|
||||
rows = []
|
||||
for i in range(0, max_planes):
|
||||
plane_config = configuration(
|
||||
PVForecastPlaneSetting(),
|
||||
eos_planes_config,
|
||||
values_prefix=["pvforecast", "planes", str(i)],
|
||||
)
|
||||
plane_rows = []
|
||||
plane_update_open = False
|
||||
if eos_planes and len(eos_planes) > i:
|
||||
plane_value = json.dumps(eos_planes[i])
|
||||
else:
|
||||
plane_value = json.dumps(None)
|
||||
for config in plane_config:
|
||||
update_error = config_update_latest.get(config["name"], {}).get("error") # type: ignore
|
||||
update_value = config_update_latest.get(config["name"], {}).get("value") # type: ignore
|
||||
update_open = config_update_latest.get(config["name"], {}).get("open") # type: ignore
|
||||
if update_open:
|
||||
planes_update_open = True
|
||||
plane_update_open = True
|
||||
# Make mypy happy - should never trigger
|
||||
if (
|
||||
not isinstance(update_error, (str, type(None)))
|
||||
or not isinstance(update_value, (str, type(None)))
|
||||
or not isinstance(update_open, (bool, type(None)))
|
||||
):
|
||||
error_msg = "update_error or update_value or update_open of wrong type."
|
||||
logger.error(error_msg)
|
||||
raise TypeError(error_msg)
|
||||
plane_rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
config["deprecated"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
rows.append(
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
H4(f"pvforecast.planes.{i}"),
|
||||
),
|
||||
DivRAligned(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(plane_value),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
*plane_rows,
|
||||
cls="space-y-4 gap-4",
|
||||
open=plane_update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
)
|
||||
|
||||
return Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
P(config_name),
|
||||
),
|
||||
DivRAligned(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(value),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
Grid(
|
||||
P(description),
|
||||
P(config_type),
|
||||
),
|
||||
# Default
|
||||
Grid(
|
||||
DivRAligned(P("default")),
|
||||
P(default),
|
||||
)
|
||||
if read_only == "rw"
|
||||
else None,
|
||||
# Set value
|
||||
Grid(
|
||||
DivRAligned(P("update")),
|
||||
Grid(
|
||||
Form(
|
||||
Input(value=config_name, type="hidden", id="key"),
|
||||
Input(value=planes_update_value, type="text", id="value"),
|
||||
hx_put="/eosdash/configuration",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
),
|
||||
)
|
||||
if read_only == "rw"
|
||||
else None,
|
||||
# Last error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
P(planes_update_error),
|
||||
)
|
||||
if planes_update_error
|
||||
else None,
|
||||
# Now come the single element configs
|
||||
*rows,
|
||||
cls="space-y-4 gap-4",
|
||||
open=planes_update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
|
||||
|
||||
def Configuration(
|
||||
eos_host: str, eos_port: Union[str, int], configuration: Optional[list[dict]] = None
|
||||
) -> Div:
|
||||
"""Create a visual representation of the configuration.
|
||||
|
||||
Args:
|
||||
eos_host (Optional[str]): The hostname of the EOS server.
|
||||
eos_port (Optional[Union[str, int]]): The port of the EOS server.
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
configuration (Optional[list[dict]]): Optional configuration. If not provided it will be
|
||||
retrievd from EOS.
|
||||
|
||||
Returns:
|
||||
Table: A `monsterui.franken.Table` component displaying configuration details.
|
||||
rows: Rows of configuration details.
|
||||
"""
|
||||
flds = "Name", "Type", "RO/RW", "Value", "Default", "Description"
|
||||
if not configuration:
|
||||
configuration = get_configuration(eos_host, eos_port)
|
||||
rows = []
|
||||
last_category = ""
|
||||
for config in get_configuration(eos_host, eos_port):
|
||||
# find some special configuration values
|
||||
max_planes = 0
|
||||
for config in configuration:
|
||||
if config["name"] == "pvforecast.max_planes":
|
||||
try:
|
||||
max_planes = int(config["value"])
|
||||
except:
|
||||
max_planes = 0
|
||||
# build visual representation
|
||||
for config in configuration:
|
||||
category = config["name"].split(".")[0]
|
||||
if category != last_category:
|
||||
rows.append(P(category))
|
||||
rows.append(H3(category))
|
||||
rows.append(DividerLine())
|
||||
last_category = category
|
||||
rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
update_error = config_update_latest.get(config["name"], {}).get("error")
|
||||
update_value = config_update_latest.get(config["name"], {}).get("value")
|
||||
update_open = config_update_latest.get(config["name"], {}).get("open")
|
||||
# Make mypy happy - should never trigger
|
||||
if (
|
||||
not isinstance(update_error, (str, type(None)))
|
||||
or not isinstance(update_value, (str, type(None)))
|
||||
or not isinstance(update_open, (bool, type(None)))
|
||||
):
|
||||
error_msg = "update_error or update_value or update_open of wrong type."
|
||||
logger.error(error_msg)
|
||||
raise TypeError(error_msg)
|
||||
if (
|
||||
config["type"]
|
||||
== "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]"
|
||||
and not config["deprecated"]
|
||||
):
|
||||
# Special configuration for PV planes
|
||||
rows.append(
|
||||
ConfigPlanesCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
max_planes,
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
else:
|
||||
rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
config["deprecated"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
)
|
||||
return Div(*rows, cls="space-y-4")
|
||||
|
||||
|
||||
def ConfigurationOrg(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table:
|
||||
"""Create a visual representation of the configuration.
|
||||
def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: str) -> P:
|
||||
"""Update configuration key and create a visual representation of the configuration.
|
||||
|
||||
Args:
|
||||
eos_host (Optional[str]): The hostname of the EOS server.
|
||||
eos_port (Optional[Union[str, int]]): The port of the EOS server.
|
||||
eos_host (str): The hostname of the EOS server.
|
||||
eos_port (Union[str, int]): The port of the EOS server.
|
||||
key (str): configuration key in dot notation
|
||||
value (str): configuration value as json string
|
||||
|
||||
Returns:
|
||||
Table: A `monsterui.franken.Table` component displaying configuration details.
|
||||
rows: Rows of configuration details.
|
||||
"""
|
||||
flds = "Name", "Type", "RO/RW", "Value", "Default", "Description"
|
||||
rows = [
|
||||
Tr(
|
||||
Td(
|
||||
config["name"],
|
||||
cls="max-w-64 text-wrap break-all",
|
||||
),
|
||||
Td(
|
||||
config["type"],
|
||||
cls="max-w-48 text-wrap break-all",
|
||||
),
|
||||
Td(
|
||||
config["read-only"],
|
||||
cls="max-w-24 text-wrap break-all",
|
||||
),
|
||||
Td(
|
||||
config["value"],
|
||||
cls="max-w-md text-wrap break-all",
|
||||
),
|
||||
Td(config["default"], cls="max-w-48 text-wrap break-all"),
|
||||
Td(
|
||||
config["description"],
|
||||
cls="max-w-prose text-wrap",
|
||||
),
|
||||
cls="",
|
||||
)
|
||||
for config in get_configuration(eos_host, eos_port)
|
||||
]
|
||||
head = Thead(*map(Th, flds), cls="text-left")
|
||||
return Table(head, Tbody(*rows), cls="w-full uk-table uk-table-divider uk-table-striped")
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
path = key.replace(".", "/")
|
||||
try:
|
||||
data = json.loads(value)
|
||||
except:
|
||||
if value in ("None", "none", "Null", "null"):
|
||||
data = None
|
||||
else:
|
||||
data = value
|
||||
|
||||
error = None
|
||||
config = None
|
||||
try:
|
||||
response = requests.put(f"{server}/v1/config/{path}", json=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
try:
|
||||
# Try to get 'detail' from the JSON response
|
||||
detail = response.json().get(
|
||||
"detail", f"No error details for data '{data}' '{response.text}'"
|
||||
)
|
||||
except ValueError:
|
||||
# Response is not JSON
|
||||
detail = f"No error details for data '{data}' '{response.text}'"
|
||||
error = f"Can not set {key} on {server}: {err}, {detail}"
|
||||
# Mark all updates as closed
|
||||
for k in config_update_latest:
|
||||
config_update_latest[k]["open"] = False
|
||||
# Remember this update as latest one
|
||||
config_update_latest[key] = {
|
||||
"error": error,
|
||||
"result": config,
|
||||
"value": value,
|
||||
"open": True,
|
||||
}
|
||||
if error or config is None:
|
||||
# Reread configuration to be shure we display actual data
|
||||
return Configuration(eos_host, eos_port)
|
||||
# Use configuration already provided
|
||||
return Configuration(eos_host, eos_port, configuration(ConfigEOS, config))
|
||||
|
@@ -24,7 +24,7 @@
|
||||
"planes": [
|
||||
{
|
||||
"peakpower": 5.0,
|
||||
"surface_azimuth": -10,
|
||||
"surface_azimuth": 170,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [
|
||||
20,
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
{
|
||||
"peakpower": 4.8,
|
||||
"surface_azimuth": -90,
|
||||
"surface_azimuth": 90,
|
||||
"surface_tilt": 7,
|
||||
"userhorizon": [
|
||||
30,
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
{
|
||||
"peakpower": 1.4,
|
||||
"surface_azimuth": -40,
|
||||
"surface_azimuth": 140,
|
||||
"surface_tilt": 60,
|
||||
"userhorizon": [
|
||||
60,
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"peakpower": 1.6,
|
||||
"surface_azimuth": 5,
|
||||
"surface_azimuth": 185,
|
||||
"surface_tilt": 45,
|
||||
"userhorizon": [
|
||||
45,
|
||||
@@ -75,9 +75,9 @@
|
||||
},
|
||||
"server": {
|
||||
"startup_eosdash": true,
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8503,
|
||||
"eosdash_host": "0.0.0.0",
|
||||
"eosdash_host": "127.0.0.1",
|
||||
"eosdash_port": 8504
|
||||
},
|
||||
"weather": {
|
||||
|
@@ -4,11 +4,10 @@ from typing import Union
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
from bokeh.models import ColumnDataSource, Range1d
|
||||
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
|
||||
from bokeh.plotting import figure
|
||||
from monsterui.franken import FT, Grid, P
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticDateTimeDataFrame
|
||||
from akkudoktoreos.server.dash.bokeh import Bokeh
|
||||
|
||||
@@ -17,8 +16,6 @@ FILE_DEMOCONFIG = DIR_DEMODATA.joinpath("democonfig.json")
|
||||
if not FILE_DEMOCONFIG.exists():
|
||||
raise ValueError(f"File does not exist: {FILE_DEMOCONFIG}")
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# bar width for 1 hour bars (time given in millseconds)
|
||||
BAR_WIDTH_1HOUR = 1000 * 60 * 60
|
||||
|
||||
@@ -76,25 +73,37 @@ def DemoElectricityPriceForecast(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
def DemoWeatherTempAir(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
def DemoWeatherTempAirHumidity(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["weather"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
x_axis_type="datetime",
|
||||
y_range=Range1d(
|
||||
predictions["weather_temp_air"].min() - 1.0, predictions["weather_temp_air"].max() + 1.0
|
||||
),
|
||||
title=f"Air Temperature Prediction ({provider})",
|
||||
title=f"Air Temperature and Humidity Prediction ({provider})",
|
||||
x_axis_label="Datetime",
|
||||
y_axis_label="Temperature [°C]",
|
||||
sizing_mode="stretch_width",
|
||||
height=400,
|
||||
)
|
||||
# Add secondary y-axis for humidity
|
||||
plot.extra_y_ranges["humidity"] = Range1d(start=-5, end=105)
|
||||
y2_axis = LinearAxis(y_range_name="humidity", axis_label="Relative Humidity [%]")
|
||||
y2_axis.axis_label_text_color = "green"
|
||||
plot.add_layout(y2_axis, "left")
|
||||
|
||||
plot.line(
|
||||
"date_time", "weather_temp_air", source=source, legend_label="Air Temperature", color="blue"
|
||||
)
|
||||
|
||||
plot.line(
|
||||
"date_time",
|
||||
"weather_relative_humidity",
|
||||
source=source,
|
||||
legend_label="Relative Humidity [%]",
|
||||
color="green",
|
||||
y_range_name="humidity",
|
||||
)
|
||||
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
@@ -135,12 +144,61 @@ def DemoWeatherIrradiance(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
def DemoLoad(predictions: pd.DataFrame, config: dict) -> FT:
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["load"]["provider"]
|
||||
if provider == "LoadAkkudoktor":
|
||||
year_energy = config["load"]["provider_settings"]["loadakkudoktor_year_energy"]
|
||||
provider = f"{provider}, {year_energy} kWh"
|
||||
|
||||
plot = figure(
|
||||
x_axis_type="datetime",
|
||||
title=f"Load Prediction ({provider})",
|
||||
x_axis_label="Datetime",
|
||||
y_axis_label="Load [W]",
|
||||
sizing_mode="stretch_width",
|
||||
height=400,
|
||||
)
|
||||
# Add secondary y-axis for stddev
|
||||
stddev_min = predictions["load_std"].min()
|
||||
stddev_max = predictions["load_std"].max()
|
||||
plot.extra_y_ranges["stddev"] = Range1d(start=stddev_min - 5, end=stddev_max + 5)
|
||||
y2_axis = LinearAxis(y_range_name="stddev", axis_label="Load Standard Deviation [W]")
|
||||
y2_axis.axis_label_text_color = "green"
|
||||
plot.add_layout(y2_axis, "left")
|
||||
|
||||
plot.line(
|
||||
"date_time",
|
||||
"load_mean",
|
||||
source=source,
|
||||
legend_label="Load mean value",
|
||||
color="red",
|
||||
)
|
||||
plot.line(
|
||||
"date_time",
|
||||
"load_mean_adjusted",
|
||||
source=source,
|
||||
legend_label="Load adjusted by measurement",
|
||||
color="blue",
|
||||
)
|
||||
plot.line(
|
||||
"date_time",
|
||||
"load_std",
|
||||
source=source,
|
||||
legend_label="Load standard deviation",
|
||||
color="green",
|
||||
y_range_name="stddev",
|
||||
)
|
||||
|
||||
return Bokeh(plot)
|
||||
|
||||
|
||||
def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
|
||||
# Get current configuration from server
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config")
|
||||
result = requests.get(f"{server}/v1/config", timeout=10)
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
detail = result.json()["detail"]
|
||||
@@ -154,12 +212,12 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
with FILE_DEMOCONFIG.open("r", encoding="utf-8") as fd:
|
||||
democonfig = json.load(fd)
|
||||
try:
|
||||
result = requests.put(f"{server}/v1/config", json=democonfig)
|
||||
result = requests.put(f"{server}/v1/config", json=democonfig, timeout=10)
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
detail = result.json()["detail"]
|
||||
# Try to reset to original config
|
||||
requests.put(f"{server}/v1/config", json=config)
|
||||
requests.put(f"{server}/v1/config", json=config, timeout=10)
|
||||
return P(
|
||||
f"Can not set demo configuration on {server}: {err}, {detail}",
|
||||
cls="text-center",
|
||||
@@ -167,12 +225,12 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
|
||||
# Update all predictions
|
||||
try:
|
||||
result = requests.post(f"{server}/v1/prediction/update")
|
||||
result = requests.post(f"{server}/v1/prediction/update", timeout=10)
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
detail = result.json()["detail"]
|
||||
# Try to reset to original config
|
||||
requests.put(f"{server}/v1/config", json=config)
|
||||
requests.put(f"{server}/v1/config", json=config, timeout=10)
|
||||
return P(
|
||||
f"Can not update predictions on {server}: {err}, {detail}",
|
||||
cls="text-center",
|
||||
@@ -184,13 +242,17 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
"keys": [
|
||||
"pvforecast_ac_power",
|
||||
"elecprice_marketprice_kwh",
|
||||
"weather_relative_humidity",
|
||||
"weather_temp_air",
|
||||
"weather_ghi",
|
||||
"weather_dni",
|
||||
"weather_dhi",
|
||||
"load_mean",
|
||||
"load_std",
|
||||
"load_mean_adjusted",
|
||||
],
|
||||
}
|
||||
result = requests.get(f"{server}/v1/prediction/dataframe", params=params)
|
||||
result = requests.get(f"{server}/v1/prediction/dataframe", params=params, timeout=10)
|
||||
result.raise_for_status()
|
||||
predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
@@ -206,12 +268,13 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
)
|
||||
|
||||
# Reset to original config
|
||||
requests.put(f"{server}/v1/config", json=config)
|
||||
requests.put(f"{server}/v1/config", json=config, timeout=10)
|
||||
|
||||
return Grid(
|
||||
DemoPVForecast(predictions, democonfig),
|
||||
DemoElectricityPriceForecast(predictions, democonfig),
|
||||
DemoWeatherTempAir(predictions, democonfig),
|
||||
DemoWeatherTempAirHumidity(predictions, democonfig),
|
||||
DemoWeatherIrradiance(predictions, democonfig),
|
||||
DemoLoad(predictions, democonfig),
|
||||
cols_max=2,
|
||||
)
|
||||
|
@@ -1,14 +1,13 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from monsterui.daisy import Loading, LoadingT
|
||||
from monsterui.franken import A, ButtonT, DivFullySpaced, P
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
config_eos = get_config()
|
||||
|
||||
|
||||
@@ -24,7 +23,7 @@ def get_alive(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
"""
|
||||
result = requests.Response()
|
||||
try:
|
||||
result = requests.get(f"http://{eos_host}:{eos_port}/v1/health")
|
||||
result = requests.get(f"http://{eos_host}:{eos_port}/v1/health", timeout=10)
|
||||
if result.status_code == 200:
|
||||
alive = result.json()["status"]
|
||||
else:
|
||||
|
@@ -7,6 +7,7 @@ import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
@@ -24,11 +25,13 @@ from fastapi.responses import (
|
||||
RedirectResponse,
|
||||
Response,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS, SettingsEOS, get_config
|
||||
from akkudoktoreos.core.cache import CacheFileStore
|
||||
from akkudoktoreos.core.ems import get_ems
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
from akkudoktoreos.core.logging import read_file_log, track_logging_config
|
||||
from akkudoktoreos.core.pydantic import (
|
||||
PydanticBaseModel,
|
||||
PydanticDateTimeData,
|
||||
@@ -48,10 +51,13 @@ from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_pr
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.server.rest.error import create_error_page
|
||||
from akkudoktoreos.server.rest.tasks import repeat_every
|
||||
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
|
||||
from akkudoktoreos.server.server import (
|
||||
get_default_host,
|
||||
is_valid_ip_or_hostname,
|
||||
wait_for_port_free,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
logger = get_logger(__name__)
|
||||
config_eos = get_config()
|
||||
measurement_eos = get_measurement()
|
||||
prediction_eos = get_prediction()
|
||||
@@ -61,6 +67,14 @@ ems_eos = get_ems()
|
||||
args = None
|
||||
|
||||
|
||||
# ----------------------
|
||||
# Logging configuration
|
||||
# ----------------------
|
||||
|
||||
logger.remove()
|
||||
track_logging_config(config_eos, "logging", None, None)
|
||||
config_eos.track_nested_value("/logging", track_logging_config)
|
||||
|
||||
# ----------------------
|
||||
# EOSdash server startup
|
||||
# ----------------------
|
||||
@@ -99,6 +113,11 @@ def start_eosdash(
|
||||
Raises:
|
||||
RuntimeError: If the EOSdash server fails to start.
|
||||
"""
|
||||
if not is_valid_ip_or_hostname(host):
|
||||
raise ValueError(f"Invalid EOSdash host: {host}")
|
||||
if not is_valid_ip_or_hostname(eos_host):
|
||||
raise ValueError(f"Invalid EOS host: {eos_host}")
|
||||
|
||||
eosdash_path = Path(__file__).parent.resolve().joinpath("eosdash.py")
|
||||
|
||||
# Do a one time check for port free to generate warnings if not so
|
||||
@@ -129,7 +148,7 @@ def start_eosdash(
|
||||
env["EOS_CONFIG_DIR"] = eos_config_dir
|
||||
|
||||
try:
|
||||
server_process = subprocess.Popen(
|
||||
server_process = subprocess.Popen( # noqa: S603
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
@@ -167,18 +186,32 @@ def cache_save() -> dict:
|
||||
return CacheFileStore().save_store()
|
||||
|
||||
|
||||
@repeat_every(seconds=float(config_eos.cache.cleanup_interval))
|
||||
def cache_cleanup_on_exception(e: Exception) -> None:
|
||||
logger.error("Cache cleanup task caught an exception: {}", e, exc_info=True)
|
||||
|
||||
|
||||
@repeat_every(
|
||||
seconds=float(config_eos.cache.cleanup_interval),
|
||||
on_exception=cache_cleanup_on_exception,
|
||||
)
|
||||
def cache_cleanup_task() -> None:
|
||||
"""Repeating task to clear cache from expired cache files."""
|
||||
logger.debug("Clear cache")
|
||||
cache_clear()
|
||||
|
||||
|
||||
def energy_management_on_exception(e: Exception) -> None:
|
||||
logger.error("Energy management task caught an exception: {}", e, exc_info=True)
|
||||
|
||||
|
||||
@repeat_every(
|
||||
seconds=10,
|
||||
wait_first=config_eos.ems.startup_delay,
|
||||
on_exception=energy_management_on_exception,
|
||||
)
|
||||
def energy_management_task() -> None:
|
||||
"""Repeating task for energy management."""
|
||||
logger.debug("Check EMS run")
|
||||
ems_eos.manage_energy()
|
||||
|
||||
|
||||
@@ -209,6 +242,7 @@ async def server_shutdown_task() -> None:
|
||||
os.kill(pid, signal.SIGTERM) # type: ignore[attr-defined,unused-ignore]
|
||||
|
||||
logger.info(f"🚀 EOS terminated, PID {pid}")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -239,10 +273,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
access_log = args.access_log
|
||||
reload = args.reload
|
||||
|
||||
host = host if host else get_default_host()
|
||||
port = port if port else 8504
|
||||
eos_host = eos_host if eos_host else get_default_host()
|
||||
eos_port = eos_port if eos_port else 8503
|
||||
host = host if host else eos_host
|
||||
port = port if port else 8504
|
||||
|
||||
eos_dir = str(config_eos.general.data_folder_path)
|
||||
eos_config_dir = str(config_eos.general.config_folder_path)
|
||||
@@ -369,7 +403,7 @@ async def fastapi_admin_server_restart_post() -> dict:
|
||||
env["EOS_DIR"] = str(config_eos.general.data_folder_path)
|
||||
env["EOS_CONFIG_DIR"] = str(config_eos.general.config_folder_path)
|
||||
|
||||
new_process = subprocess.Popen(
|
||||
new_process = subprocess.Popen( # noqa: S603
|
||||
[
|
||||
sys.executable,
|
||||
]
|
||||
@@ -485,7 +519,9 @@ def fastapi_config_put_key(
|
||||
path: str = FastapiPath(
|
||||
..., description="The nested path to the configuration key (e.g., general/latitude)."
|
||||
),
|
||||
value: Any = Body(..., description="The value to assign to the specified configuration path."),
|
||||
value: Optional[Any] = Body(
|
||||
None, description="The value to assign to the specified configuration path (can be None)."
|
||||
),
|
||||
) -> ConfigEOS:
|
||||
"""Update a nested key or index in the config model.
|
||||
|
||||
@@ -497,13 +533,13 @@ def fastapi_config_put_key(
|
||||
configuration (ConfigEOS): The current configuration after the update.
|
||||
"""
|
||||
try:
|
||||
config_eos.set_config_value(path, value)
|
||||
except IndexError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
config_eos.set_nested_value(path, value)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error on update of configuration '{path}','{value}': {e}\n{trace}",
|
||||
)
|
||||
|
||||
return config_eos
|
||||
|
||||
@@ -523,7 +559,7 @@ def fastapi_config_get_key(
|
||||
value (Any): The value of the selected nested key.
|
||||
"""
|
||||
try:
|
||||
return config_eos.get_config_value(path)
|
||||
return config_eos.get_nested_value(path)
|
||||
except IndexError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except KeyError as e:
|
||||
@@ -532,6 +568,52 @@ def fastapi_config_get_key(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/v1/logging/log", tags=["logging"])
|
||||
async def fastapi_logging_get_log(
|
||||
limit: int = Query(100, description="Maximum number of log entries to return."),
|
||||
level: Optional[str] = Query(None, description="Filter by log level (e.g., INFO, ERROR)."),
|
||||
contains: Optional[str] = Query(None, description="Filter logs containing this substring."),
|
||||
regex: Optional[str] = Query(None, description="Filter logs by matching regex in message."),
|
||||
from_time: Optional[str] = Query(
|
||||
None, description="Start time (ISO format) for filtering logs."
|
||||
),
|
||||
to_time: Optional[str] = Query(None, description="End time (ISO format) for filtering logs."),
|
||||
tail: bool = Query(False, description="If True, returns the most recent lines (tail mode)."),
|
||||
) -> JSONResponse:
|
||||
"""Get structured log entries from the EOS log file.
|
||||
|
||||
Filters and returns log entries based on the specified query parameters. The log
|
||||
file is expected to contain newline-delimited JSON entries.
|
||||
|
||||
Args:
|
||||
limit (int): Maximum number of entries to return.
|
||||
level (Optional[str]): Filter logs by severity level (e.g., DEBUG, INFO).
|
||||
contains (Optional[str]): Return only logs that include this string in the message.
|
||||
regex (Optional[str]): Return logs that match this regular expression in the message.
|
||||
from_time (Optional[str]): ISO 8601 timestamp to filter logs not older than this.
|
||||
to_time (Optional[str]): ISO 8601 timestamp to filter logs not newer than this.
|
||||
tail (bool): If True, fetch the most recent log entries (like `tail`).
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON list of log entries.
|
||||
"""
|
||||
log_path = config_eos.logging.file_path
|
||||
try:
|
||||
logs = read_file_log(
|
||||
log_path=log_path,
|
||||
limit=limit,
|
||||
level=level,
|
||||
contains=contains,
|
||||
regex=regex,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
tail=tail,
|
||||
)
|
||||
return JSONResponse(content=logs)
|
||||
except Exception as e:
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/v1/measurement/keys", tags=["measurement"])
|
||||
def fastapi_measurement_keys_get() -> list[str]:
|
||||
"""Get a list of available measurement keys."""
|
||||
@@ -844,7 +926,11 @@ def fastapi_prediction_update(
|
||||
try:
|
||||
prediction_eos.update_data(force_update=force_update, force_enable=force_enable)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error on prediction update: {e}")
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error on prediction update: {e}\n{trace}",
|
||||
)
|
||||
return Response()
|
||||
|
||||
|
||||
@@ -868,7 +954,9 @@ def fastapi_prediction_update_provider(
|
||||
try:
|
||||
provider.update_data(force_update=force_update, force_enable=force_enable)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error on update of provider: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Error on update of provider '{provider_id}': {e}"
|
||||
)
|
||||
return Response()
|
||||
|
||||
|
||||
@@ -1138,9 +1226,14 @@ def fastapi_optimize(
|
||||
|
||||
# Perform optimization simulation
|
||||
opt_class = optimization_problem(verbose=bool(config_eos.server.verbose))
|
||||
result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour, **extra_args)
|
||||
# print(result)
|
||||
return result
|
||||
try:
|
||||
result = opt_class.optimierung_ems(
|
||||
parameters=parameters, start_hour=start_hour, **extra_args
|
||||
)
|
||||
# print(result)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Optimize error: {e}.")
|
||||
|
||||
|
||||
@app.get("/visualization_results.pdf", response_class=PdfResponse, tags=["optimize"])
|
||||
@@ -1194,7 +1287,7 @@ def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectRespons
|
||||
if port is None:
|
||||
port = 8504
|
||||
# Make hostname Windows friendly
|
||||
if host == "0.0.0.0" and os.name == "nt":
|
||||
if host == "0.0.0.0" and os.name == "nt": # noqa: S104
|
||||
host = "localhost"
|
||||
url = f"http://{host}:{port}/"
|
||||
error_page = create_error_page(
|
||||
@@ -1211,7 +1304,7 @@ Did you want to connect to <a href="{url}" class="back-button">EOSdash</a>?
|
||||
|
||||
# Make hostname Windows friendly
|
||||
host = str(config_eos.server.eosdash_host)
|
||||
if host == "0.0.0.0" and os.name == "nt":
|
||||
if host == "0.0.0.0" and os.name == "nt": # noqa: S104
|
||||
host = "localhost"
|
||||
if host and config_eos.server.eosdash_port:
|
||||
# Redirect to EOSdash server
|
||||
@@ -1222,45 +1315,46 @@ Did you want to connect to <a href="{url}" class="back-button">EOSdash</a>?
|
||||
return RedirectResponse(url="/docs", status_code=303)
|
||||
|
||||
|
||||
def run_eos(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None:
|
||||
def run_eos(host: str, port: int, log_level: str, reload: bool) -> None:
|
||||
"""Run the EOS server with the specified configurations.
|
||||
|
||||
This function starts the EOS server using the Uvicorn ASGI server. It accepts
|
||||
arguments for the host, port, log level, access log, and reload options. The
|
||||
log level is converted to lowercase to ensure compatibility with Uvicorn's
|
||||
expected log level format. If an error occurs while attempting to bind the
|
||||
server to the specified host and port, an error message is logged and the
|
||||
application exits.
|
||||
Starts the EOS server using the Uvicorn ASGI server. Logs an error and exits if
|
||||
binding to the host and port fails.
|
||||
|
||||
Parameters:
|
||||
host (str): The hostname to bind the server to.
|
||||
port (int): The port number to bind the server to.
|
||||
log_level (str): The log level for the server. Options include "critical", "error",
|
||||
"warning", "info", "debug", and "trace".
|
||||
access_log (bool): Whether to enable or disable the access log. Set to True to enable.
|
||||
reload (bool): Whether to enable or disable auto-reload. Set to True for development.
|
||||
Args:
|
||||
host (str): Hostname to bind the server to.
|
||||
port (int): Port number to bind the server to.
|
||||
log_level (str): Log level for the server. One of
|
||||
"critical", "error", "warning", "info", "debug", or "trace".
|
||||
reload (bool): Enable or disable auto-reload. True for development.
|
||||
|
||||
Returns:
|
||||
None
|
||||
None
|
||||
"""
|
||||
# Make hostname Windows friendly
|
||||
if host == "0.0.0.0" and os.name == "nt":
|
||||
if host == "0.0.0.0" and os.name == "nt": # noqa: S104
|
||||
host = "localhost"
|
||||
|
||||
# Setup console logging level using nested value
|
||||
# - triggers logging configuration by track_logging_config
|
||||
if log_level.upper() in LOGGING_LEVELS:
|
||||
config_eos.set_nested_value("logging/console_level", log_level.upper())
|
||||
|
||||
# Wait for EOS port to be free - e.g. in case of restart
|
||||
wait_for_port_free(port, timeout=120, waiting_app_name="EOS")
|
||||
|
||||
try:
|
||||
# Let uvicorn run the fastAPI app
|
||||
uvicorn.run(
|
||||
"akkudoktoreos.server.eos:app",
|
||||
host=host,
|
||||
port=port,
|
||||
log_level=log_level.lower(), # Convert log_level to lowercase
|
||||
access_log=access_log,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
reload=reload,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not bind to host {host}:{port}. Error: {e}")
|
||||
logger.exception("Failed to start uvicorn server.")
|
||||
raise e
|
||||
|
||||
|
||||
@@ -1275,7 +1369,7 @@ def main() -> None:
|
||||
Command-line Arguments:
|
||||
--host (str): Host for the EOS server (default: value from config).
|
||||
--port (int): Port for the EOS server (default: value from config).
|
||||
--log_level (str): Log level for the server. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info").
|
||||
--log_level (str): Log level for the server console. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info").
|
||||
--access_log (bool): Enable or disable access log. Options: True or False (default: False).
|
||||
--reload (bool): Enable or disable auto-reload. Useful for development. Options: True or False (default: False).
|
||||
"""
|
||||
@@ -1299,14 +1393,8 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--log_level",
|
||||
type=str,
|
||||
default="info",
|
||||
help='Log level for the server. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info")',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--access_log",
|
||||
type=bool,
|
||||
default=False,
|
||||
help="Enable or disable access log. Options: True or False (default: True)",
|
||||
default="none",
|
||||
help='Log level for the server console. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "none")',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reload",
|
||||
@@ -1321,7 +1409,7 @@ def main() -> None:
|
||||
port = args.port if args.port else 8503
|
||||
|
||||
try:
|
||||
run_eos(host, port, args.log_level, args.access_log, args.reload)
|
||||
run_eos(host, port, args.log_level, args.reload)
|
||||
except:
|
||||
sys.exit(1)
|
||||
|
||||
|
@@ -8,23 +8,21 @@ from typing import Optional
|
||||
import psutil
|
||||
import uvicorn
|
||||
from fasthtml.common import FileResponse, JSONResponse
|
||||
from loguru import logger
|
||||
from monsterui.core import FastHTML, Theme
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.server.dash.bokeh import BokehJS
|
||||
from akkudoktoreos.server.dash.components import Page
|
||||
|
||||
# Pages
|
||||
from akkudoktoreos.server.dash.configuration import Configuration
|
||||
from akkudoktoreos.server.dash.admin import Admin
|
||||
from akkudoktoreos.server.dash.bokeh import BokehJS
|
||||
from akkudoktoreos.server.dash.components import Page
|
||||
from akkudoktoreos.server.dash.configuration import ConfigKeyUpdate, Configuration
|
||||
from akkudoktoreos.server.dash.demo import Demo
|
||||
from akkudoktoreos.server.dash.footer import Footer
|
||||
from akkudoktoreos.server.dash.hello import Hello
|
||||
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
|
||||
|
||||
# from akkudoktoreos.server.dash.altair import AltairJS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
config_eos = get_config()
|
||||
|
||||
# The favicon for EOSdash
|
||||
@@ -37,8 +35,7 @@ args: Optional[argparse.Namespace] = None
|
||||
|
||||
|
||||
# Get frankenui and tailwind headers via CDN using Theme.green.headers()
|
||||
# Add altair headers
|
||||
# hdrs=(Theme.green.headers(highlightjs=True), AltairJS,)
|
||||
# Add Bokeh headers
|
||||
hdrs = (
|
||||
Theme.green.headers(highlightjs=True),
|
||||
BokehJS,
|
||||
@@ -94,6 +91,7 @@ def get_eosdash(): # type: ignore
|
||||
"EOSdash": "/eosdash/hello",
|
||||
"Config": "/eosdash/configuration",
|
||||
"Demo": "/eosdash/demo",
|
||||
"Admin": "/eosdash/admin",
|
||||
},
|
||||
Hello(),
|
||||
Footer(*eos_server()),
|
||||
@@ -121,6 +119,21 @@ def get_eosdash_hello(): # type: ignore
|
||||
return Hello()
|
||||
|
||||
|
||||
@app.get("/eosdash/admin")
|
||||
def get_eosdash_admin(): # type: ignore
|
||||
"""Serves the EOSdash Admin page.
|
||||
|
||||
Returns:
|
||||
Admin: The Admin page component.
|
||||
"""
|
||||
return Admin(*eos_server())
|
||||
|
||||
|
||||
@app.post("/eosdash/admin")
|
||||
def post_eosdash_admin(data: dict): # type: ignore
|
||||
return Admin(*eos_server(), data)
|
||||
|
||||
|
||||
@app.get("/eosdash/configuration")
|
||||
def get_eosdash_configuration(): # type: ignore
|
||||
"""Serves the EOSdash Configuration page.
|
||||
@@ -131,6 +144,11 @@ def get_eosdash_configuration(): # type: ignore
|
||||
return Configuration(*eos_server())
|
||||
|
||||
|
||||
@app.put("/eosdash/configuration")
|
||||
def put_eosdash_configuration(data: dict): # type: ignore
|
||||
return ConfigKeyUpdate(*eos_server(), data["key"], data["value"])
|
||||
|
||||
|
||||
@app.get("/eosdash/demo")
|
||||
def get_eosdash_demo(): # type: ignore
|
||||
"""Serves the EOSdash Demo page.
|
||||
@@ -223,7 +241,7 @@ def run_eosdash() -> None:
|
||||
reload = False
|
||||
|
||||
# Make hostname Windows friendly
|
||||
if eosdash_host == "0.0.0.0" and os.name == "nt":
|
||||
if eosdash_host == "0.0.0.0" and os.name == "nt": # noqa: S104
|
||||
eosdash_host = "localhost"
|
||||
|
||||
# Wait for EOSdash port to be free - e.g. in case of restart
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
ERROR_PAGE_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -86,6 +88,6 @@ def create_error_page(
|
||||
return (
|
||||
ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code)
|
||||
.replace("ERROR_TITLE", error_title)
|
||||
.replace("ERROR_MESSAGE", error_message)
|
||||
.replace("ERROR_DETAILS", error_details)
|
||||
.replace("ERROR_MESSAGE", html.escape(error_message))
|
||||
.replace("ERROR_DETAILS", html.escape(error_details))
|
||||
)
|
||||
|
@@ -1,22 +1,51 @@
|
||||
"""Server Module."""
|
||||
|
||||
import os
|
||||
import ipaddress
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, Union
|
||||
|
||||
import psutil
|
||||
from loguru import logger
|
||||
from pydantic import Field, IPvAnyAddress, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_default_host() -> str:
|
||||
if os.name == "nt":
|
||||
return "127.0.0.1"
|
||||
return "0.0.0.0"
|
||||
"""Default host for EOS."""
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
def is_valid_ip_or_hostname(value: str) -> bool:
|
||||
"""Validate whether a string is a valid IP address (IPv4 or IPv6) or hostname.
|
||||
|
||||
This function first attempts to interpret the input as an IP address using the
|
||||
standard library `ipaddress` module. If that fails, it checks whether the input
|
||||
is a valid hostname according to RFC 1123, which allows domain names consisting
|
||||
of alphanumeric characters and hyphens, with specific length and structure rules.
|
||||
|
||||
Args:
|
||||
value (str): The input string to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the input is a valid IP address or hostname, False otherwise.
|
||||
"""
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if len(value) > 253:
|
||||
return False
|
||||
|
||||
hostname_regex = re.compile(
|
||||
r"^(?=.{1,253}$)(?!-)[A-Z\d-]{1,63}(?<!-)"
|
||||
r"(?:\.(?!-)[A-Z\d-]{1,63}(?<!-))*\.?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
return bool(hostname_regex.fullmatch(value))
|
||||
|
||||
|
||||
def wait_for_port_free(port: int, timeout: int = 0, waiting_app_name: str = "App") -> bool:
|
||||
@@ -110,6 +139,8 @@ class ServerCommonSettings(SettingsBaseModel):
|
||||
cls, value: Optional[Union[str, IPvAnyAddress]]
|
||||
) -> Optional[Union[str, IPvAnyAddress]]:
|
||||
if isinstance(value, str):
|
||||
if not is_valid_ip_or_hostname(value):
|
||||
raise ValueError(f"Invalid host: {value}")
|
||||
if value.lower() in ("localhost", "loopback"):
|
||||
value = "127.0.0.1"
|
||||
return value
|
||||
|
@@ -27,13 +27,12 @@ from datetime import date, datetime, timedelta
|
||||
from typing import Any, List, Literal, Optional, Tuple, Union, overload
|
||||
|
||||
import pendulum
|
||||
from loguru import logger
|
||||
from pendulum import Date, DateTime, Duration
|
||||
from pendulum.tz.timezone import Timezone
|
||||
from timezonefinder import TimezoneFinder
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
MAX_DURATION_STRING_LENGTH = 350
|
||||
|
||||
|
||||
@overload
|
||||
@@ -153,22 +152,22 @@ def to_datetime(
|
||||
fmt_tz = f"{fmt} z"
|
||||
dt_tz = f"{date_input} {in_timezone}"
|
||||
dt = pendulum.from_format(dt_tz, fmt_tz)
|
||||
logger.debug(
|
||||
logger.trace(
|
||||
f"Str Fmt converted: {dt}, tz={dt.tz} from {date_input}, tz={in_timezone}"
|
||||
)
|
||||
break
|
||||
except ValueError as e:
|
||||
logger.debug(f"{date_input}, {fmt}, {e}")
|
||||
logger.trace(f"{date_input}, {fmt}, {e}")
|
||||
dt = None
|
||||
else:
|
||||
# DateTime input with timezone info
|
||||
try:
|
||||
dt = pendulum.parse(date_input)
|
||||
logger.debug(
|
||||
logger.trace(
|
||||
f"Pendulum Fmt converted: {dt}, tz={dt.tz} from {date_input}, tz={in_timezone}"
|
||||
)
|
||||
except pendulum.parsing.exceptions.ParserError as e:
|
||||
logger.debug(f"Date string {date_input} does not match any Pendulum formats: {e}")
|
||||
logger.trace(f"Date string {date_input} does not match any Pendulum formats: {e}")
|
||||
dt = None
|
||||
if dt is None:
|
||||
# Some special values
|
||||
@@ -180,7 +179,7 @@ def to_datetime(
|
||||
timestamp = float(date_input)
|
||||
dt = pendulum.from_timestamp(timestamp, tz="UTC")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.debug(f"Date string {date_input} does not match timestamp format: {e}")
|
||||
logger.trace(f"Date string {date_input} does not match timestamp format: {e}")
|
||||
dt = None
|
||||
if dt is None:
|
||||
raise ValueError(f"Date string {date_input} does not match any known formats.")
|
||||
@@ -201,7 +200,7 @@ def to_datetime(
|
||||
|
||||
# Represent in target timezone
|
||||
dt_in_tz = dt.in_timezone(in_timezone)
|
||||
logger.debug(
|
||||
logger.trace(
|
||||
f"\nTimezone adapted to: {in_timezone}\nfrom: {dt} tz={dt.timezone}\nto: {dt_in_tz} tz={dt_in_tz.tz}"
|
||||
)
|
||||
dt = dt_in_tz
|
||||
@@ -276,7 +275,7 @@ def to_duration(
|
||||
duration = pendulum.parse(input_value)
|
||||
return duration - duration.start_of("day")
|
||||
except pendulum.parsing.exceptions.ParserError as e:
|
||||
logger.debug(f"Invalid Pendulum time string format '{input_value}': {e}")
|
||||
logger.trace(f"Invalid Pendulum time string format '{input_value}': {e}")
|
||||
|
||||
# Handle strings like "2 days 5 hours 30 minutes"
|
||||
total_seconds = 0
|
||||
@@ -287,6 +286,11 @@ def to_duration(
|
||||
"second": 1,
|
||||
}
|
||||
|
||||
# Mitigate ReDoS vulnerability (#494) by checking input string length.
|
||||
if len(input_value) > MAX_DURATION_STRING_LENGTH:
|
||||
raise ValueError(
|
||||
f"Input string exceeds maximum allowed length ({MAX_DURATION_STRING_LENGTH})."
|
||||
)
|
||||
# Regular expression to match time components like '2 days', '5 hours', etc.
|
||||
matches = re.findall(r"(\d+)\s*(days?|hours?|minutes?|seconds?)", input_value)
|
||||
|
||||
|
@@ -35,6 +35,8 @@ def get_model_structure_from_examples(
|
||||
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
|
||||
|
@@ -4,9 +4,6 @@ from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UtilsCommonSettings(SettingsBaseModel):
|
||||
|
@@ -12,16 +12,18 @@ import pendulum
|
||||
from matplotlib.backends.backend_pdf import PdfPages
|
||||
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.ems import EnergyManagement
|
||||
from akkudoktoreos.optimization.genetic import OptimizationParameters
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
matplotlib.use(
|
||||
"Agg"
|
||||
) # non-interactive backend that can only write to files, backend needed to stay in main thread.
|
||||
|
||||
|
||||
debug_visualize: bool = False
|
||||
|
||||
|
||||
class VisualizationReport(ConfigMixin):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -162,14 +164,18 @@ class VisualizationReport(ConfigMixin):
|
||||
|
||||
# Format the time axis
|
||||
plt.gca().xaxis.set_major_formatter(
|
||||
mdates.DateFormatter("%Y-%m-%d")
|
||||
mdates.DateFormatter("%Y-%m-%d", tz=self.config.general.timezone)
|
||||
) # Show date and time
|
||||
plt.gca().xaxis.set_major_locator(
|
||||
mdates.DayLocator(interval=1, tz=None)
|
||||
mdates.DayLocator(interval=1, tz=self.config.general.timezone)
|
||||
) # Major ticks every day
|
||||
plt.gca().xaxis.set_minor_locator(mdates.HourLocator(interval=3, tz=None))
|
||||
plt.gca().xaxis.set_minor_locator(
|
||||
mdates.HourLocator(interval=2, tz=self.config.general.timezone)
|
||||
)
|
||||
# Minor ticks every 6 hours
|
||||
plt.gca().xaxis.set_minor_formatter(mdates.DateFormatter("%H"))
|
||||
plt.gca().xaxis.set_minor_formatter(
|
||||
mdates.DateFormatter("%H", tz=self.config.general.timezone)
|
||||
)
|
||||
# plt.gcf().autofmt_xdate(rotation=45, which="major")
|
||||
# Auto-format the x-axis for readability
|
||||
|
||||
@@ -202,8 +208,10 @@ class VisualizationReport(ConfigMixin):
|
||||
hours_since_start = [(t - timestamps[0]).total_seconds() / 3600 for t in timestamps]
|
||||
# ax2.set_xticks(timestamps[::48]) # Set ticks every 12 hours
|
||||
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::48]])
|
||||
ax2.set_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks
|
||||
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 24]])
|
||||
# ax2.set_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks
|
||||
ax2.set_xticks(timestamps[:: len(timestamps) // 12]) # Select 10 evenly spaced ticks
|
||||
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 24]])
|
||||
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 12]])
|
||||
if x2label:
|
||||
ax2.set_xlabel(x2label)
|
||||
|
||||
@@ -431,15 +439,17 @@ def prepare_visualize(
|
||||
parameters: OptimizationParameters,
|
||||
results: dict,
|
||||
filename: str = "visualization_results.pdf",
|
||||
start_hour: Optional[int] = 0,
|
||||
start_hour: int = 0,
|
||||
) -> None:
|
||||
global debug_visualize
|
||||
|
||||
report = VisualizationReport(filename)
|
||||
next_full_hour_date = pendulum.now(report.config.general.timezone).start_of("hour").add(hours=1)
|
||||
next_full_hour_date = EnergyManagement.set_start_datetime()
|
||||
# Group 1:
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date, # start_date
|
||||
next_full_hour_date,
|
||||
[
|
||||
parameters.ems.gesamtlast,
|
||||
parameters.ems.gesamtlast[start_hour:],
|
||||
],
|
||||
title="Load Profile",
|
||||
# xlabel="Hours", # not enough space
|
||||
@@ -447,9 +457,9 @@ def prepare_visualize(
|
||||
labels=["Total Load (Wh)"],
|
||||
)
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date, # start_date
|
||||
next_full_hour_date,
|
||||
[
|
||||
parameters.ems.pv_prognose_wh,
|
||||
parameters.ems.pv_prognose_wh[start_hour:],
|
||||
],
|
||||
title="PV Forecast",
|
||||
# xlabel="Hours", # not enough space
|
||||
@@ -457,8 +467,15 @@ def prepare_visualize(
|
||||
)
|
||||
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date, # start_date
|
||||
[np.full(len(parameters.ems.gesamtlast), parameters.ems.einspeiseverguetung_euro_pro_wh)],
|
||||
next_full_hour_date,
|
||||
[
|
||||
np.full(
|
||||
len(parameters.ems.gesamtlast) - start_hour,
|
||||
parameters.ems.einspeiseverguetung_euro_pro_wh[start_hour:]
|
||||
if isinstance(parameters.ems.einspeiseverguetung_euro_pro_wh, list)
|
||||
else parameters.ems.einspeiseverguetung_euro_pro_wh,
|
||||
)
|
||||
],
|
||||
title="Remuneration",
|
||||
# xlabel="Hours", # not enough space
|
||||
ylabel="€/Wh",
|
||||
@@ -466,9 +483,9 @@ def prepare_visualize(
|
||||
)
|
||||
if parameters.temperature_forecast:
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date, # start_date
|
||||
next_full_hour_date,
|
||||
[
|
||||
parameters.temperature_forecast,
|
||||
parameters.temperature_forecast[start_hour:],
|
||||
],
|
||||
title="Temperature Forecast",
|
||||
# xlabel="Hours", # not enough space
|
||||
@@ -517,21 +534,35 @@ def prepare_visualize(
|
||||
)
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date, # start_date
|
||||
[parameters.ems.strompreis_euro_pro_wh],
|
||||
title="Electricity Price",
|
||||
[parameters.ems.strompreis_euro_pro_wh[start_hour:]],
|
||||
# title="Electricity Price", # not enough space
|
||||
# xlabel="Date", # not enough space
|
||||
ylabel="Electricity Price (€/Wh)",
|
||||
x2label=None, # not enough space
|
||||
)
|
||||
|
||||
labels = list(
|
||||
item
|
||||
for sublist in zip(
|
||||
list(str(i) for i in range(0, 23, 2)), list(str(" ") for i in range(0, 23, 2))
|
||||
)
|
||||
for item in sublist
|
||||
)
|
||||
labels = labels[start_hour:] + labels
|
||||
|
||||
report.create_bar_chart(
|
||||
list(str(i) for i in range(len(results["ac_charge"]))),
|
||||
[results["ac_charge"], results["dc_charge"], results["discharge_allowed"]],
|
||||
labels,
|
||||
[
|
||||
results["ac_charge"][start_hour:],
|
||||
results["dc_charge"][start_hour:],
|
||||
results["discharge_allowed"][start_hour:],
|
||||
],
|
||||
title="AC/DC Charging and Discharge Overview",
|
||||
ylabel="Relative Power (0-1) / Discharge (0 or 1)",
|
||||
label_names=["AC Charging (relative)", "DC Charging (relative)", "Discharge Allowed"],
|
||||
colors=["blue", "green", "red"],
|
||||
bottom=3,
|
||||
xlabels=labels,
|
||||
)
|
||||
report.finalize_group()
|
||||
|
||||
@@ -614,7 +645,7 @@ def prepare_visualize(
|
||||
|
||||
if filtered_balance.size > 0 or filtered_losses.size > 0:
|
||||
report.finalize_group()
|
||||
if logger.level == "DEBUG" or results["fixed_seed"]:
|
||||
if debug_visualize or results["fixed_seed"]:
|
||||
report.create_line_chart(
|
||||
0,
|
||||
[
|
||||
@@ -639,6 +670,8 @@ def prepare_visualize(
|
||||
|
||||
def generate_example_report(filename: str = "example_report.pdf") -> None:
|
||||
"""Generate example visualization report."""
|
||||
global debug_visualize
|
||||
|
||||
report = VisualizationReport(filename, "test")
|
||||
x_hours = 0 # Define x-axis start values (e.g., hours)
|
||||
|
||||
@@ -710,9 +743,9 @@ def generate_example_report(filename: str = "example_report.pdf") -> None:
|
||||
|
||||
report.finalize_group() # Finalize the third group of charts
|
||||
|
||||
logger.setLevel("DEBUG") # set level for example report
|
||||
debug_visualize = True # set level for example report
|
||||
|
||||
if logger.level == "DEBUG":
|
||||
if debug_visualize:
|
||||
report.create_line_chart(
|
||||
x_hours,
|
||||
[np.array([0.2, 0.25, 0.3, 0.35])],
|
||||
|
@@ -16,35 +16,75 @@ import pendulum
|
||||
import psutil
|
||||
import pytest
|
||||
import requests
|
||||
from _pytest.logging import LogCaptureFixture
|
||||
from loguru import logger
|
||||
from xprocess import ProcessStarter, XProcess
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS, get_config
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.server.server import get_default_host
|
||||
|
||||
logger = get_logger(__name__)
|
||||
# -----------------------------------------------
|
||||
# Adapt pytest logging handling to Loguru logging
|
||||
# -----------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def caplog(caplog: LogCaptureFixture):
|
||||
"""Propagate Loguru logs to the pytest caplog handler."""
|
||||
handler_id = logger.add(
|
||||
caplog.handler,
|
||||
format="{message}",
|
||||
level=0,
|
||||
filter=lambda record: record["level"].no >= caplog.handler.level,
|
||||
enqueue=False, # Set to 'True' if your test is spawning child processes.
|
||||
)
|
||||
yield caplog
|
||||
try:
|
||||
logger.remove(handler_id)
|
||||
except:
|
||||
# May already be deleted
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reportlog(pytestconfig):
|
||||
"""Propagate Loguru logs to the pytest terminal reporter."""
|
||||
logging_plugin = pytestconfig.pluginmanager.getplugin("logging-plugin")
|
||||
handler_id = logger.add(logging_plugin.report_handler, format="{message}")
|
||||
yield
|
||||
try:
|
||||
logger.remove(handler_id)
|
||||
except:
|
||||
# May already be deleted
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def propagate_logs():
|
||||
"""Deal with the pytest --log-cli-level command-line flag.
|
||||
|
||||
This option controls the standard logging logs, not loguru ones.
|
||||
For this reason, we first install a PropagateHandler for compatibility.
|
||||
"""
|
||||
class PropagateHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
if logging.getLogger(record.name).isEnabledFor(record.levelno):
|
||||
logging.getLogger(record.name).handle(record)
|
||||
|
||||
logger.remove()
|
||||
logger.add(PropagateHandler(), format="{message}")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def disable_debug_logging(scope="session", autouse=True):
|
||||
"""Automatically disable debug logging for all tests."""
|
||||
original_levels = {}
|
||||
root_logger = logging.getLogger()
|
||||
logger.remove() # Remove all loggers
|
||||
logger.add(sys.stderr, level="INFO") # Only show INFO and above
|
||||
|
||||
original_levels[root_logger] = root_logger.level
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
for logger_name, logger in logging.root.manager.loggerDict.items():
|
||||
if isinstance(logger, logging.Logger):
|
||||
original_levels[logger] = logger.level
|
||||
if logger.level <= logging.DEBUG:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
yield
|
||||
|
||||
for logger, level in original_levels.items():
|
||||
logger.setLevel(level)
|
||||
|
||||
# -----------------------------------------------
|
||||
# Provide pytest options for specific test setups
|
||||
# -----------------------------------------------
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
@@ -144,6 +184,7 @@ def cfg_non_existent(request):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def user_cwd(config_default_dirs):
|
||||
"""Patch cwd provided by module pathlib.Path.cwd."""
|
||||
with patch(
|
||||
"pathlib.Path.cwd",
|
||||
return_value=config_default_dirs[1],
|
||||
@@ -153,6 +194,7 @@ def user_cwd(config_default_dirs):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def user_config_dir(config_default_dirs):
|
||||
"""Patch user_config_dir provided by module platformdirs."""
|
||||
with patch(
|
||||
"akkudoktoreos.config.config.user_config_dir",
|
||||
return_value=str(config_default_dirs[0]),
|
||||
@@ -162,6 +204,7 @@ def user_config_dir(config_default_dirs):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def user_data_dir(config_default_dirs):
|
||||
"""Patch user_data_dir provided by module platformdirs."""
|
||||
with patch(
|
||||
"akkudoktoreos.config.config.user_data_dir",
|
||||
return_value=str(config_default_dirs[-1] / "data"),
|
||||
@@ -189,14 +232,18 @@ def config_eos(
|
||||
config_file_cwd = config_default_dirs[1] / ConfigEOS.CONFIG_FILE_NAME
|
||||
assert not config_file.exists()
|
||||
assert not config_file_cwd.exists()
|
||||
|
||||
config_eos = get_config()
|
||||
config_eos.reset_settings()
|
||||
assert config_file == config_eos.general.config_file_path
|
||||
assert config_file.exists()
|
||||
assert not config_file_cwd.exists()
|
||||
|
||||
# Check user data directory pathes (config_default_dirs[-1] == data_default_dir_user)
|
||||
assert config_default_dirs[-1] / "data" == config_eos.general.data_folder_path
|
||||
assert config_default_dirs[-1] / "data/cache" == config_eos.cache.path()
|
||||
assert config_default_dirs[-1] / "data/output" == config_eos.general.data_output_path
|
||||
assert config_default_dirs[-1] / "data/output/eos.log" == config_eos.logging.file_path
|
||||
return config_eos
|
||||
|
||||
|
||||
@@ -429,7 +476,7 @@ def server(xprocess, config_eos, config_default_dirs) -> Generator[str, None, No
|
||||
Provides URL of the server.
|
||||
"""
|
||||
# create url/port info to the server
|
||||
url = "http://0.0.0.0:8503"
|
||||
url = "http://127.0.0.1:8503"
|
||||
|
||||
class Starter(ProcessStarter):
|
||||
# Set environment before any subprocess run, to keep custom config dir
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user