mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-11 10:26:38 +00:00
fix: unify mypy environments for local checks and CI (#1291)
The isolated pre-commit mypy hook previously omitted runtime type information that make mypy used, hiding errors involving dependencies such as Pydantic and Pendulum. Makefile, pre-commit and CI now run the same full-project typing policy in the development environment defined by uv.lock. - Use uv run --locked --exact --extra dev and the same mypy arguments for Makefile and the local hook. Check all of src and tests, including on configuration-only changes. - Pin Python 3.13 for local development and the pre-commit CI job, and install the locked pre-commit version in CI. - Disable incremental analysis because existing Pendulum cache state changes mypy 2.3.1 diagnostics. Document the policy, the performance tradeoff and the existing typing debt. - Add a regression test that exercises Makefile, the hook and the CI command in a temporary project, accepting valid dependency types and detecting deliberate Pydantic/Pendulum assignment errors. Resolve the newly detected mypy diagnostics. - Enable the numpydantic and Pydantic mypy plugins, retaining strict Pydantic constructor typing with init_typed = true. Validate raw/coercible payloads through model_validate. - Propagate concrete record, provider and time-window types through generic collections, factories and lookup methods. Preserve runtime field inspection and generated time-window documentation. - Align Pendulum annotations with actual factory/arithmetic results while retaining Pydantic validation adapters at runtime. Correct optional values, array boundaries, REST handlers and plotting interfaces. - Add pinned scipy-stubs and types-psutil, update uv.lock, and supply the plugins' dependencies. - Add runtime regression coverage for validated path defaults, normalized time-series metadata, generic field inspection, invalid timestamps and unsupported provider imports. Runtime and compatibility details: - Validate path defaults as Path objects while retaining raw string defaults needed by migration serialization with exclude_defaults. - Normalize feed-in tariff lists and default charge rates to NumPy arrays; reject missing timestamps/uninitialized values explicitly. Importing into a provider without import support returns HTTP 400. - Public JSON schemas and OpenAPI structure match main (excluding the generated version). Signed-off-by: dr-dimitry Signed-off-by: dr-dimitry Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: dr-dimitri <87113560+dr-dimitri@users.noreply.github.com> Co-authored-by: Normann <github@koldrack.com>
This commit is contained in:
co-authored by
dr-dimitri
Normann
parent
5b584cbb57
commit
1abdd345c4
@@ -14,4 +14,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-python@v7
|
- uses: actions/setup-python@v7
|
||||||
- uses: pre-commit/action@v3.0.1
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
|
- name: Install uv
|
||||||
|
run: python -m pip install uv==0.12.9
|
||||||
|
- name: Run pre-commit in the locked development environment
|
||||||
|
run: uv run --locked --exact --extra dev pre-commit run --all-files --show-diff-on-failure
|
||||||
|
|||||||
@@ -30,17 +30,14 @@ repos:
|
|||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
# --- Static type checking ---
|
# --- Static type checking ---
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: local
|
||||||
rev: v2.3.1
|
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
additional_dependencies:
|
name: mypy (locked development environment)
|
||||||
- types-requests==2.33.0.20260712
|
entry: uv run --locked --exact --extra dev python -m mypy --config-file pyproject.toml
|
||||||
- pandas-stubs==3.0.5.260730
|
language: unsupported
|
||||||
- tokenize-rt==6.2.0
|
|
||||||
- types-docutils==0.23.0.20260827
|
|
||||||
- types-PyYaml==6.0.12.20260815
|
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
|
||||||
# --- Markdown linter ---
|
# --- Markdown linter ---
|
||||||
- repo: https://github.com/jackdewinter/pymarkdown
|
- repo: https://github.com/jackdewinter/pymarkdown
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
+40
-4
@@ -30,7 +30,7 @@ message style checks.
|
|||||||
Use `uv` to create the virtual environment and install development dependencies.
|
Use `uv` to create the virtual environment and install development dependencies.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --extra dev
|
uv sync --locked --extra dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Install make to get access to helpful shortcuts (documentation generation, manual formatting, etc.).
|
Install make to get access to helpful shortcuts (documentation generation, manual formatting, etc.).
|
||||||
@@ -58,16 +58,52 @@ Our code style checks use [`pre-commit`](https://pre-commit.com).
|
|||||||
To run formatting automatically before every commit:
|
To run formatting automatically before every commit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pre-commit install
|
uv run --locked --extra dev pre-commit install
|
||||||
uv run pre-commit install --hook-type commit-msg --hook-type pre-push
|
uv run --locked --extra dev pre-commit install --hook-type commit-msg --hook-type pre-push
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run them manually:
|
Or run them manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pre-commit run --all-files
|
uv run --locked --extra dev pre-commit run --all-files
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Static typing
|
||||||
|
|
||||||
|
Use `uv` on your `PATH` and the Python version pinned in `.python-version` (also used by the
|
||||||
|
pre-commit CI job). The supported typing entry points are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make mypy
|
||||||
|
uv run --locked --extra dev pre-commit run mypy --all-files
|
||||||
|
```
|
||||||
|
|
||||||
|
Both run `uv run --locked --exact --extra dev python -m mypy --config-file pyproject.toml`.
|
||||||
|
CI runs the same local pre-commit hook. The environment includes all runtime dependencies and
|
||||||
|
development stubs from `uv.lock`, including the type information supplied by Pydantic and Pendulum.
|
||||||
|
`--locked` rejects an out-of-date lockfile instead of updating it, and `--exact` removes packages
|
||||||
|
outside the selected locked dependencies. Keep the Makefile and hook commands identical.
|
||||||
|
|
||||||
|
`[tool.mypy]` in `pyproject.toml` defines the policy for all of `src` and `tests`, targeting Python
|
||||||
|
3.13 and Linux. The hook always checks this complete scope, including on configuration-only changes.
|
||||||
|
It does not add the old mirror hook's `--ignore-missing-imports` or `--scripts-are-modules` defaults.
|
||||||
|
Only the existing per-module missing-import exceptions in `pyproject.toml` apply.
|
||||||
|
Incremental analysis is disabled because mypy 2.3.1 produces different Pendulum diagnostics with
|
||||||
|
warm and empty caches. Each entry point therefore performs a full analysis; this costs time but
|
||||||
|
keeps diagnostics independent of cache history without suppressing checks.
|
||||||
|
|
||||||
|
To regression-test the entry points run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --locked --extra dev pytest -q --finalize tests/test_typingmypytoolchain.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This test creates a temporary project and a fresh locked development environment and hook/type-check
|
||||||
|
caches. It verifies valid Pydantic and Pendulum assignments, then deliberate type errors in both
|
||||||
|
`src` and `tests`, through Makefile, a configuration-only hook run, and the CI command. All probes
|
||||||
|
stay in the temporary project. It may download locked packages; set `UV_CACHE_DIR` to an empty
|
||||||
|
temporary directory as well to verify without a warm package cache.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
Use `pytest` to run tests locally:
|
Use `pytest` to run tests locally:
|
||||||
|
|||||||
@@ -5,14 +5,16 @@
|
|||||||
|
|
||||||
# Use uv for all program actions
|
# Use uv for all program actions
|
||||||
UV := uv
|
UV := uv
|
||||||
PYTHON := $(UV) run python
|
UV_PYTHON := python3
|
||||||
|
PYTHON := $(UV) run --python $(UV_PYTHON) python
|
||||||
PYTEST := $(UV) run pytest
|
PYTEST := $(UV) run pytest
|
||||||
MYPY := $(UV) run mypy
|
MYPY := $(UV) run --locked --exact --extra dev python -m mypy --config-file pyproject.toml
|
||||||
PRECOMMIT := $(UV) run pre-commit
|
PRECOMMIT := $(UV) run --locked --extra dev pre-commit
|
||||||
COMMITIZEN := $(UV) run cz
|
COMMITIZEN := $(UV) run cz
|
||||||
|
|
||||||
# - Take VERSION from version.py
|
# - Take VERSION from version.py
|
||||||
VERSION := $(shell $(PYTHON) scripts/get_version.py)
|
# Evaluate only for targets that need it, so typing never runs an unlocked uv command.
|
||||||
|
VERSION = $(shell $(PYTHON) scripts/get_version.py)
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: help
|
all: help
|
||||||
@@ -160,7 +162,7 @@ format:
|
|||||||
gitlint:
|
gitlint:
|
||||||
$(COMMITIZEN) check --rev-range main..HEAD
|
$(COMMITIZEN) check --rev-range main..HEAD
|
||||||
|
|
||||||
# Target to format code.
|
# Check the complete typing policy in the locked development environment.
|
||||||
mypy:
|
mypy:
|
||||||
$(MYPY)
|
$(MYPY)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| homeassistant | `EOS_ADAPTER__HOMEASSISTANT` | `HomeAssistantAdapterCommonSettings` | `rw` | `required` | Home Assistant adapter settings. |
|
| homeassistant | `EOS_ADAPTER__HOMEASSISTANT` | `HomeAssistantAdapterCommonSettings` | `rw` | `required` | Home Assistant adapter settings. |
|
||||||
| nodered | `EOS_ADAPTER__NODERED` | `NodeREDAdapterCommonSettings` | `rw` | `required` | NodeRED adapter settings. |
|
| nodered | `EOS_ADAPTER__NODERED` | `NodeREDAdapterCommonSettings` | `rw` | `required` | NodeRED adapter settings. |
|
||||||
| provider | `EOS_ADAPTER__PROVIDER` | `list[str] | None` | `rw` | `None` | List of adapter provider id(s) of provider(s) to be used. |
|
| provider | `EOS_ADAPTER__PROVIDER` | `Optional[list[str]]` | `rw` | `None` | List of adapter provider id(s) of provider(s) to be used. |
|
||||||
| providers | | `list[str]` | `ro` | `N/A` | Available adapter provider ids. |
|
| providers | | `list[str]` | `ro` | `N/A` | Available adapter provider ids. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
@@ -103,8 +103,8 @@ There are two URLs that are used:
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| host | `str | None` | `rw` | `127.0.0.1` | Node-RED server IP address. Defaults to 127.0.0.1. |
|
| host | `Optional[str]` | `rw` | `127.0.0.1` | Node-RED server IP address. Defaults to 127.0.0.1. |
|
||||||
| port | `int | None` | `rw` | `1880` | Node-RED server IP port number. Defaults to 1880. |
|
| port | `Optional[int]` | `rw` | `1880` | Node-RED server IP port number. Defaults to 1880. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -134,21 +134,21 @@ There are two URLs that are used:
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| config_entity_ids | `dict[str, str] | None` | `rw` | `None` | Mapping of EOS config keys to Home Assistant entity IDs.
|
| config_entity_ids | `Optional[dict[str, str]]` | `rw` | `None` | Mapping of EOS config keys to Home Assistant entity IDs.
|
||||||
The config key has to be given by a ‘/’-separated path
|
The config key has to be given by a ‘/’-separated path
|
||||||
e.g. devices/batteries/0/capacity_wh |
|
e.g. devices/batteries/0/capacity_wh |
|
||||||
| device_instruction_entity_ids | `list[str] | None` | `rw` | `None` | Entity IDs for device (resource) instructions to be updated by EOS.
|
| device_instruction_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity IDs for device (resource) instructions to be updated by EOS.
|
||||||
The device ids (resource ids) have to be prepended by 'sensor.eos_' to build the entity_id.
|
The device ids (resource ids) have to be prepended by 'sensor.eos_' to build the entity_id.
|
||||||
E.g. The instruction for device id 'battery1' becomes the entity_id 'sensor.eos_battery1'. |
|
E.g. The instruction for device id 'battery1' becomes the entity_id 'sensor.eos_battery1'. |
|
||||||
| device_measurement_entity_ids | `dict[str, str] | None` | `rw` | `None` | Mapping of EOS measurement keys used by device (resource) simulations to Home Assistant entity IDs. |
|
| device_measurement_entity_ids | `Optional[dict[str, str]]` | `rw` | `None` | Mapping of EOS measurement keys used by device (resource) simulations to Home Assistant entity IDs. |
|
||||||
| eos_device_instruction_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs for energy management instructions available at EOS. |
|
| eos_device_instruction_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs for energy management instructions available at EOS. |
|
||||||
| eos_solution_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs for optimization solution available at EOS. |
|
| eos_solution_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs for optimization solution available at EOS. |
|
||||||
| grid_export_emr_entity_ids | `list[str] | None` | `rw` | `None` | Entity ID(s) of export to grid energy meter readings [kWh] |
|
| grid_export_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of export to grid energy meter readings [kWh] |
|
||||||
| grid_import_emr_entity_ids | `list[str] | None` | `rw` | `None` | Entity ID(s) of import from grid energy meter readings [kWh] |
|
| grid_import_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of import from grid energy meter readings [kWh] |
|
||||||
| homeassistant_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs available at Home Assistant. |
|
| homeassistant_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs available at Home Assistant. |
|
||||||
| load_emr_entity_ids | `list[str] | None` | `rw` | `None` | Entity ID(s) of load energy meter readings [kWh] |
|
| load_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of load energy meter readings [kWh] |
|
||||||
| pv_production_emr_entity_ids | `list[str] | None` | `rw` | `None` | Entity ID(s) of PV production energy meter readings [kWh] |
|
| pv_production_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of PV production energy meter readings [kWh] |
|
||||||
| solution_entity_ids | `list[str] | None` | `rw` | `None` | Entity IDs for optimization solution keys to be updated by EOS.
|
| solution_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity IDs for optimization solution keys to be updated by EOS.
|
||||||
The solution keys have to be prepended by 'sensor.eos_' to build the entity_id.
|
The solution keys have to be prepended by 'sensor.eos_' to build the entity_id.
|
||||||
E.g. solution key 'battery1_idle_op_mode' becomes the entity_id 'sensor.eos_battery1_idle_op_mode'. |
|
E.g. solution key 'battery1_idle_op_mode' becomes the entity_id 'sensor.eos_battery1_idle_op_mode'. |
|
||||||
:::
|
:::
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| cleanup_interval | `EOS_CACHE__CLEANUP_INTERVAL` | `float` | `rw` | `300.0` | Intervall in seconds for EOS file cache cleanup. |
|
| cleanup_interval | `EOS_CACHE__CLEANUP_INTERVAL` | `float` | `rw` | `300.0` | Intervall in seconds for EOS file cache cleanup. |
|
||||||
| subpath | `EOS_CACHE__SUBPATH` | `pathlib.Path | None` | `rw` | `cache` | Sub-path for the EOS cache data directory. |
|
| subpath | `EOS_CACHE__SUBPATH` | `Optional[pathlib.Path]` | `rw` | `cache` | Sub-path for the EOS cache data directory. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ Attributes:
|
|||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| autosave_interval_sec | `EOS_DATABASE__AUTOSAVE_INTERVAL_SEC` | `int | None` | `rw` | `10` | Automatic saving interval [seconds].
|
| autosave_interval_sec | `EOS_DATABASE__AUTOSAVE_INTERVAL_SEC` | `Optional[int]` | `rw` | `10` | Automatic saving interval [seconds].
|
||||||
Set to None to disable automatic saving. |
|
Set to None to disable automatic saving. |
|
||||||
| batch_size | `EOS_DATABASE__BATCH_SIZE` | `int` | `rw` | `100` | Number of records to process in batch operations. |
|
| batch_size | `EOS_DATABASE__BATCH_SIZE` | `int` | `rw` | `100` | Number of records to process in batch operations. |
|
||||||
| compaction_interval_sec | `EOS_DATABASE__COMPACTION_INTERVAL_SEC` | `int | None` | `rw` | `3600` | Interval in between automatic tiered compaction runs [seconds].
|
| compaction_interval_sec | `EOS_DATABASE__COMPACTION_INTERVAL_SEC` | `Optional[int]` | `rw` | `3600` | Interval in between automatic tiered compaction runs [seconds].
|
||||||
Compaction downsamples old records to reduce storage while retaining coverage. Set to None to disable automatic compaction. |
|
Compaction downsamples old records to reduce storage while retaining coverage. Set to None to disable automatic compaction. |
|
||||||
| compression_level | `EOS_DATABASE__COMPRESSION_LEVEL` | `int` | `rw` | `9` | Compression level for database record data. |
|
| compression_level | `EOS_DATABASE__COMPRESSION_LEVEL` | `int` | `rw` | `9` | Compression level for database record data. |
|
||||||
| initial_load_window_h | `EOS_DATABASE__INITIAL_LOAD_WINDOW_H` | `int | None` | `rw` | `None` | Specifies the default duration of the initial load window when loading records from the database, in hours. If set to None, the full available range is loaded. The window is centered around the current time by default, unless a different center time is specified. Different database namespaces may define their own default windows. |
|
| initial_load_window_h | `EOS_DATABASE__INITIAL_LOAD_WINDOW_H` | `Optional[int]` | `rw` | `None` | Specifies the default duration of the initial load window when loading records from the database, in hours. If set to None, the full available range is loaded. The window is centered around the current time by default, unless a different center time is specified. Different database namespaces may define their own default windows. |
|
||||||
| keep_duration_h | `EOS_DATABASE__KEEP_DURATION_H` | `int | None` | `rw` | `None` | Default maximum duration records shall be kept in database [hours, none].
|
| keep_duration_h | `EOS_DATABASE__KEEP_DURATION_H` | `Optional[int]` | `rw` | `None` | Default maximum duration records shall be kept in database [hours, none].
|
||||||
None indicates forever. Database namespaces may have diverging definitions. |
|
None indicates forever. Database namespaces may have diverging definitions. |
|
||||||
| provider | `EOS_DATABASE__PROVIDER` | `str | None` | `rw` | `None` | Database provider id of provider to be used. |
|
| provider | `EOS_DATABASE__PROVIDER` | `Optional[str]` | `rw` | `None` | Database provider id of provider to be used. |
|
||||||
| providers | | `List[str]` | `ro` | `N/A` | Return available database provider ids. |
|
| providers | | `List[str]` | `ro` | `N/A` | Return available database provider ids. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|||||||
@@ -7,15 +7,15 @@
|
|||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| batteries | `EOS_DEVICES__BATTERIES` | `list[akkudoktoreos.devices.devices.BatteriesCommonSettings] | None` | `rw` | `None` | List of battery devices |
|
| batteries | `EOS_DEVICES__BATTERIES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of battery devices |
|
||||||
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `list[akkudoktoreos.devices.devices.BatteriesCommonSettings] | None` | `rw` | `None` | List of electric vehicle devices |
|
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of electric vehicle devices |
|
||||||
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings] | None` | `rw` | `None` | List of home appliances |
|
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `Optional[list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings]]` | `rw` | `None` | List of home appliances |
|
||||||
| inverters | `EOS_DEVICES__INVERTERS` | `list[akkudoktoreos.devices.devices.InverterCommonSettings] | None` | `rw` | `None` | List of inverters |
|
| inverters | `EOS_DEVICES__INVERTERS` | `Optional[list[akkudoktoreos.devices.devices.InverterCommonSettings]]` | `rw` | `None` | List of inverters |
|
||||||
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `int | None` | `rw` | `None` | Maximum number of batteries that can be set |
|
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries that can be set |
|
||||||
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `int | None` | `rw` | `None` | Maximum number of electric vehicles that can be set |
|
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of electric vehicles that can be set |
|
||||||
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `int | None` | `rw` | `None` | Maximum number of home_appliances that can be set |
|
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home_appliances that can be set |
|
||||||
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `int | None` | `rw` | `None` | Maximum number of inverters that can be set |
|
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters that can be set |
|
||||||
| measurement_keys | | `list[str] | None` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. |
|
| measurement_keys | | `Optional[list[str]]` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -207,12 +207,12 @@
|
|||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| ac_to_dc_efficiency | `float` | `rw` | `1.0` | Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss). |
|
| ac_to_dc_efficiency | `float` | `rw` | `1.0` | Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss). |
|
||||||
| battery_id | `str | None` | `rw` | `None` | ID of battery controlled by this inverter. |
|
| battery_id | `Optional[str]` | `rw` | `None` | ID of battery controlled by this inverter. |
|
||||||
| dc_to_ac_efficiency | `float` | `rw` | `1.0` | Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss). |
|
| dc_to_ac_efficiency | `float` | `rw` | `1.0` | Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss). |
|
||||||
| device_id | `str` | `rw` | `required` | ID of device |
|
| device_id | `str` | `rw` | `required` | ID of device |
|
||||||
| max_ac_charge_power_w | `float | None` | `rw` | `None` | Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging. |
|
| max_ac_charge_power_w | `Optional[float]` | `rw` | `None` | Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging. |
|
||||||
| max_power_w | `float | None` | `rw` | `None` | Maximum power [W]. |
|
| max_power_w | `Optional[float]` | `rw` | `None` | Maximum power [W]. |
|
||||||
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
|
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -290,10 +290,10 @@ the model serialisable without timezone state.
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
|
| date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
|
||||||
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
|
| day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
|
||||||
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
|
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
|
||||||
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
|
| locale | `Optional[str]` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
|
||||||
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
|
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
@@ -374,8 +374,8 @@ as a cohesive unit for scheduling and availability checking.
|
|||||||
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
|
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
|
||||||
| device_id | `str` | `rw` | `required` | ID of device |
|
| device_id | `str` | `rw` | `required` | ID of device |
|
||||||
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
|
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
|
||||||
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
|
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
|
||||||
| time_windows | `akkudoktoreos.config.configabc.TimeWindowSequence | None` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
|
| time_windows | `Optional[akkudoktoreos.config.configabc.TimeWindowSequence]` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -452,20 +452,20 @@ as a cohesive unit for scheduling and availability checking.
|
|||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| capacity_wh | `int` | `rw` | `8000` | Capacity [Wh]. |
|
| capacity_wh | `int` | `rw` | `8000` | Capacity [Wh]. |
|
||||||
| charge_rates | `list[float] | None` | `rw` | `[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]` | Charge rates as factor of maximum charging power [0.00 ... 1.00]. None triggers fallback to default charge-rates. |
|
| charge_rates | `Optional[list[float]]` | `rw` | `[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]` | Charge rates as factor of maximum charging power [0.00 ... 1.00]. None triggers fallback to default charge-rates. |
|
||||||
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
|
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
|
||||||
| device_id | `str` | `rw` | `required` | ID of device |
|
| device_id | `str` | `rw` | `required` | ID of device |
|
||||||
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
|
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
|
||||||
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh]. |
|
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh]. |
|
||||||
| max_charge_power_w | `float | None` | `rw` | `5000` | Maximum charging power [W]. |
|
| max_charge_power_w | `Optional[float]` | `rw` | `5000` | Maximum charging power [W]. |
|
||||||
| max_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
|
| max_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
|
||||||
| measurement_key_power_3_phase_sym_w | `str` | `ro` | `N/A` | Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W]. |
|
| measurement_key_power_3_phase_sym_w | `str` | `ro` | `N/A` | Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W]. |
|
||||||
| measurement_key_power_l1_w | `str` | `ro` | `N/A` | Measurement key for the L1 power the battery is charged or discharged with [W]. |
|
| measurement_key_power_l1_w | `str` | `ro` | `N/A` | Measurement key for the L1 power the battery is charged or discharged with [W]. |
|
||||||
| measurement_key_power_l2_w | `str` | `ro` | `N/A` | Measurement key for the L2 power the battery is charged or discharged with [W]. |
|
| measurement_key_power_l2_w | `str` | `ro` | `N/A` | Measurement key for the L2 power the battery is charged or discharged with [W]. |
|
||||||
| measurement_key_power_l3_w | `str` | `ro` | `N/A` | Measurement key for the L3 power the battery is charged or discharged with [W]. |
|
| measurement_key_power_l3_w | `str` | `ro` | `N/A` | Measurement key for the L3 power the battery is charged or discharged with [W]. |
|
||||||
| measurement_key_soc_factor | `str` | `ro` | `N/A` | Measurement key for the battery state of charge (SoC) as factor of total capacity [0.0 ... 1.0]. |
|
| measurement_key_soc_factor | `str` | `ro` | `N/A` | Measurement key for the battery state of charge (SoC) as factor of total capacity [0.0 ... 1.0]. |
|
||||||
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the battery stati that are measurements. |
|
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the battery stati that are measurements. |
|
||||||
| min_charge_power_w | `float | None` | `rw` | `50` | Minimum charging power [W]. |
|
| min_charge_power_w | `Optional[float]` | `rw` | `50` | Minimum charging power [W]. |
|
||||||
| min_soc_percentage | `int` | `rw` | `0` | Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging |
|
| min_soc_percentage | `int` | `rw` | `0` | Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| elecfeefixed | `EOS_ELECFEE__ELECFEEFIXED` | `ElecFeeFixedCommonSettings` | `rw` | `required` | Fixed electricity fees provider settings. |
|
| elecfeefixed | `EOS_ELECFEE__ELECFEEFIXED` | `ElecFeeFixedCommonSettings` | `rw` | `required` | Fixed electricity fees provider settings. |
|
||||||
| elecfeeimport | `EOS_ELECFEE__ELECFEEIMPORT` | `ElecFeeImportCommonSettings` | `rw` | `required` | Electricity fees import provider settings. |
|
| elecfeeimport | `EOS_ELECFEE__ELECFEEIMPORT` | `ElecFeeImportCommonSettings` | `rw` | `required` | Electricity fees import provider settings. |
|
||||||
| provider | `EOS_ELECFEE__PROVIDER` | `str | None` | `rw` | `None` | Electricity fee provider id of provider to be used. |
|
| provider | `EOS_ELECFEE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity fee provider id of provider to be used. |
|
||||||
| providers | | `list[str]` | `ro` | `N/A` | Available electricity fee provider ids. |
|
| providers | | `list[str]` | `ro` | `N/A` | Available electricity fee provider ids. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
@@ -91,8 +91,8 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import elecfee data from. |
|
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import elecfee data from. |
|
||||||
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of electricity fee forecast value lists. |
|
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of electricity fee forecast value lists. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -124,12 +124,12 @@ This model extends `TimeWindow` by associating a value with the defined time int
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
|
| date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
|
||||||
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
|
| day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
|
||||||
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
|
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
|
||||||
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
|
| locale | `Optional[str]` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
|
||||||
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
|
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
|
||||||
| value | `float | None` | `rw` | `None` | Value applicable during this time window. |
|
| value | `Optional[float]` | `rw` | `None` | Value applicable during this time window. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price provider settings. |
|
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price provider settings. |
|
||||||
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Electricity price import provider settings. |
|
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Electricity price import provider settings. |
|
||||||
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
|
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
|
||||||
| provider | `EOS_ELECPRICE__PROVIDER` | `str | None` | `rw` | `None` | Electricity price provider id of provider to be used. |
|
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
|
||||||
| providers | | `list[str]` | `ro` | `N/A` | Available electricity price provider ids. |
|
| providers | | `list[str]` | `ro` | `N/A` | Available electricity price provider ids. |
|
||||||
| smard | `EOS_ELECPRICE__SMARD` | `ElecPriceSMARDCommonSettings` | `rw` | `required` | SMARD electricity price provider settings. |
|
| smard | `EOS_ELECPRICE__SMARD` | `ElecPriceSMARDCommonSettings` | `rw` | `required` | SMARD electricity price provider settings. |
|
||||||
| tibber | `EOS_ELECPRICE__TIBBER` | `ElecPriceTibberCommonSettings` | `rw` | `required` | Tibber electricity price provider settings. |
|
| tibber | `EOS_ELECPRICE__TIBBER` | `ElecPriceTibberCommonSettings` | `rw` | `required` | Tibber electricity price provider settings. |
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| access_token | `str | None` | `rw` | `None` | Tibber API access token. |
|
| access_token | `Optional[str]` | `rw` | `None` | Tibber API access token. |
|
||||||
| home_id | `str | None` | `rw` | `None` | Optional Tibber home id. If omitted, the first home with a subscription is used. |
|
| home_id | `Optional[str]` | `rw` | `None` | Optional Tibber home id. If omitted, the first home with a subscription is used. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -196,8 +196,8 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import elecprice data from. |
|
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import elecprice data from. |
|
||||||
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of electricity price forecast value lists. |
|
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of electricity price forecast value lists. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
| energycharts | `EOS_FEEDINTARIFF__ENERGYCHARTS` | `FeedInTariffEnergyChartsCommonSettings` | `rw` | `required` | EnergyCharts feed in tariff provider settings. |
|
| energycharts | `EOS_FEEDINTARIFF__ENERGYCHARTS` | `FeedInTariffEnergyChartsCommonSettings` | `rw` | `required` | EnergyCharts feed in tariff provider settings. |
|
||||||
| feedintarifffixed | `EOS_FEEDINTARIFF__FEEDINTARIFFFIXED` | `FeedInTariffFixedCommonSettings` | `rw` | `required` | Fixed feed in tariff provider settings. |
|
| feedintarifffixed | `EOS_FEEDINTARIFF__FEEDINTARIFFFIXED` | `FeedInTariffFixedCommonSettings` | `rw` | `required` | Fixed feed in tariff provider settings. |
|
||||||
| feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. |
|
| feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. |
|
||||||
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `str | None` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
|
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
|
||||||
| providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. |
|
| providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. |
|
||||||
| smard | `EOS_FEEDINTARIFF__SMARD` | `FeedInTariffSMARDCommonSettings` | `rw` | `required` | SMARD feed in tariff provider settings. |
|
| smard | `EOS_FEEDINTARIFF__SMARD` | `FeedInTariffSMARDCommonSettings` | `rw` | `required` | SMARD feed in tariff provider settings. |
|
||||||
:::
|
:::
|
||||||
@@ -123,8 +123,8 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import feed in tariff data from. |
|
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import feed in tariff data from. |
|
||||||
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of feed in tariff forecast value lists. |
|
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of feed in tariff forecast value lists. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,18 @@
|
|||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| config_file_path | | `pathlib.Path | None` | `ro` | `N/A` | Path to EOS configuration file. |
|
| config_file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration file. |
|
||||||
| config_folder_path | | `pathlib.Path | None` | `ro` | `N/A` | Path to EOS configuration directory. |
|
| config_folder_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration directory. |
|
||||||
| config_save_interval_sec | `EOS_GENERAL__CONFIG_SAVE_INTERVAL_SEC` | `int` | `rw` | `60` | Automatic configuration file saving interval [seconds]. |
|
| config_save_interval_sec | `EOS_GENERAL__CONFIG_SAVE_INTERVAL_SEC` | `int` | `rw` | `60` | Automatic configuration file saving interval [seconds]. |
|
||||||
| config_save_mode | `EOS_GENERAL__CONFIG_SAVE_MODE` | `<enum 'ConfigSaveMode'>` | `rw` | `AUTOMATIC` | Configuration file save mode for configuration changes ['MANUAL', 'AUTOMATIC']. Defaults to 'AUTOMATIC'. |
|
| config_save_mode | `EOS_GENERAL__CONFIG_SAVE_MODE` | `<enum 'ConfigSaveMode'>` | `rw` | `AUTOMATIC` | Configuration file save mode for configuration changes ['MANUAL', 'AUTOMATIC']. Defaults to 'AUTOMATIC'. |
|
||||||
| data_folder_path | `EOS_GENERAL__DATA_FOLDER_PATH` | `Path` | `rw` | `required` | Path to EOS data folder. |
|
| data_folder_path | `EOS_GENERAL__DATA_FOLDER_PATH` | `Path` | `rw` | `required` | Path to EOS data folder. |
|
||||||
| data_output_path | | `pathlib.Path | None` | `ro` | `N/A` | Computed data_output_path based on data_folder_path. |
|
| data_output_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Computed data_output_path based on data_folder_path. |
|
||||||
| data_output_subpath | `EOS_GENERAL__DATA_OUTPUT_SUBPATH` | `pathlib.Path | None` | `rw` | `output` | Sub-path for the EOS output data folder. |
|
| data_output_subpath | `EOS_GENERAL__DATA_OUTPUT_SUBPATH` | `Optional[pathlib.Path]` | `rw` | `output` | Sub-path for the EOS output data folder. |
|
||||||
| home_assistant_addon | `EOS_GENERAL__HOME_ASSISTANT_ADDON` | `bool` | `rw` | `required` | EOS is running as home assistant add-on. |
|
| home_assistant_addon | `EOS_GENERAL__HOME_ASSISTANT_ADDON` | `bool` | `rw` | `required` | EOS is running as home assistant add-on. |
|
||||||
| latitude | `EOS_GENERAL__LATITUDE` | `float | None` | `rw` | `52.52` | Latitude in decimal degrees between -90 and 90. North is positive (ISO 19115) (°) |
|
| latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees between -90 and 90. North is positive (ISO 19115) (°) |
|
||||||
| longitude | `EOS_GENERAL__LONGITUDE` | `float | None` | `rw` | `13.405` | Longitude in decimal degrees within -180 to 180 (°) |
|
| longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees within -180 to 180 (°) |
|
||||||
| timezone | | `str | None` | `ro` | `N/A` | Computed timezone based on latitude and longitude. |
|
| timezone | | `Optional[str]` | `ro` | `N/A` | Computed timezone based on latitude and longitude. |
|
||||||
| version | `EOS_GENERAL__VERSION` | `str | None` | `rw` | `None` | Configuration file version. |
|
| version | `EOS_GENERAL__VERSION` | `Optional[str]` | `rw` | `None` | Configuration file version. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| loadakkudoktor | `EOS_LOAD__LOADAKKUDOKTOR` | `LoadAkkudoktorCommonSettings` | `rw` | `required` | LoadAkkudoktor provider settings. |
|
| loadakkudoktor | `EOS_LOAD__LOADAKKUDOKTOR` | `LoadAkkudoktorCommonSettings` | `rw` | `required` | LoadAkkudoktor provider settings. |
|
||||||
| loadimport | `EOS_LOAD__LOADIMPORT` | `LoadImportCommonSettings` | `rw` | `required` | LoadImport provider settings. |
|
| loadimport | `EOS_LOAD__LOADIMPORT` | `LoadImportCommonSettings` | `rw` | `required` | LoadImport provider settings. |
|
||||||
| provider | `EOS_LOAD__PROVIDER` | `str | None` | `rw` | `None` | Load provider id of provider to be used. |
|
| provider | `EOS_LOAD__PROVIDER` | `Optional[str]` | `rw` | `None` | Load provider id of provider to be used. |
|
||||||
| providers | | `list[str]` | `ro` | `N/A` | Available load provider ids. |
|
| providers | | `list[str]` | `ro` | `N/A` | Available load provider ids. |
|
||||||
| vrm | `EOS_LOAD__VRM` | `LoadVrmCommonSettings` | `rw` | `required` | Victron Remote Management (VRM) provider settings. |
|
| vrm | `EOS_LOAD__VRM` | `LoadVrmCommonSettings` | `rw` | `required` | Victron Remote Management (VRM) provider settings. |
|
||||||
:::
|
:::
|
||||||
@@ -111,8 +111,8 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import load data from. |
|
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import load data from. |
|
||||||
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of load forecast value lists. |
|
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of load forecast value lists. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| loadakkudoktor_year_energy_kwh | `float | None` | `rw` | `None` | Yearly energy consumption (kWh). |
|
| loadakkudoktor_year_energy_kwh | `Optional[float]` | `rw` | `None` | Yearly energy consumption (kWh). |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| api_level | `EOS_LOGGING__API_LEVEL` | `str | None` | `rw` | `None` | Logging level for API response. |
|
| api_level | `EOS_LOGGING__API_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level for API response. |
|
||||||
| console_level | `EOS_LOGGING__CONSOLE_LEVEL` | `str | None` | `rw` | `None` | Logging level for logging to console. |
|
| console_level | `EOS_LOGGING__CONSOLE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level for logging to console. |
|
||||||
| file_level | `EOS_LOGGING__FILE_LEVEL` | `str | None` | `rw` | `None` | Logging level for logging to file. |
|
| file_level | `EOS_LOGGING__FILE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level for logging to file. |
|
||||||
| file_path | | `pathlib.Path | None` | `ro` | `N/A` | Computed log file path based on data output path. |
|
| file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Computed log file path based on data output path. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| grid_export_emr_keys | `EOS_MEASUREMENT__GRID_EXPORT_EMR_KEYS` | `list[str] | None` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy export to grid [kWh]. |
|
| grid_export_emr_keys | `EOS_MEASUREMENT__GRID_EXPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy export to grid [kWh]. |
|
||||||
| grid_import_emr_keys | `EOS_MEASUREMENT__GRID_IMPORT_EMR_KEYS` | `list[str] | None` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy import from grid [kWh]. |
|
| grid_import_emr_keys | `EOS_MEASUREMENT__GRID_IMPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy import from grid [kWh]. |
|
||||||
| historic_hours | `EOS_MEASUREMENT__HISTORIC_HOURS` | `int | None` | `rw` | `17520` | Number of hours into the past for measurement data |
|
| historic_hours | `EOS_MEASUREMENT__HISTORIC_HOURS` | `Optional[int]` | `rw` | `17520` | Number of hours into the past for measurement data |
|
||||||
| keys | | `list[str]` | `ro` | `N/A` | The keys of the measurements that can be stored. |
|
| keys | | `list[str]` | `ro` | `N/A` | The keys of the measurements that can be stored. |
|
||||||
| load_emr_keys | `EOS_MEASUREMENT__LOAD_EMR_KEYS` | `list[str] | None` | `rw` | `None` | The keys of the measurements that are energy meter readings of a load [kWh]. |
|
| load_emr_keys | `EOS_MEASUREMENT__LOAD_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of a load [kWh]. |
|
||||||
| pv_production_emr_keys | `EOS_MEASUREMENT__PV_PRODUCTION_EMR_KEYS` | `list[str] | None` | `rw` | `None` | The keys of the measurements that are PV production energy meter readings [kWh]. |
|
| pv_production_emr_keys | `EOS_MEASUREMENT__PV_PRODUCTION_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are PV production energy meter readings [kWh]. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -98,13 +98,13 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| generations | `int | None` | `rw` | `400` | Number of generations to evolve [>= 10]. Defaults to 400. |
|
| generations | `Optional[int]` | `rw` | `400` | Number of generations to evolve [>= 10]. Defaults to 400. |
|
||||||
| horizon | `int` | `ro` | `N/A` | Number of optimization steps. |
|
| horizon | `int` | `ro` | `N/A` | Number of optimization steps. |
|
||||||
| horizon_hours | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
|
| horizon_hours | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
|
||||||
| individuals | `int | None` | `rw` | `300` | Number of individuals (solutions) in the population [>= 10]. Defaults to 300. |
|
| individuals | `Optional[int]` | `rw` | `300` | Number of individuals (solutions) in the population [>= 10]. Defaults to 300. |
|
||||||
| interval_sec | `int` | `ro` | `N/A` | The optimization interval [sec]. Fixed to 1 hour (3600 seconds). |
|
| interval_sec | `int` | `ro` | `N/A` | The optimization interval [sec]. Fixed to 1 hour (3600 seconds). |
|
||||||
| penalties | `dict[str, float | int | str]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
|
| penalties | `dict[str, Union[float, int, str]]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
|
||||||
| seed | `int | None` | `rw` | `None` | Random seed for reproducibility. None = random. |
|
| seed | `Optional[int]` | `rw` | `None` | Random seed for reproducibility. None = random. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -163,13 +163,13 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| generations | `int | None` | `rw` | `400` | Number of generations to evolve [>= 10]. Defaults to 400. |
|
| generations | `Optional[int]` | `rw` | `400` | Number of generations to evolve [>= 10]. Defaults to 400. |
|
||||||
| horizon | `int` | `ro` | `N/A` | Number of optimization steps. |
|
| horizon | `int` | `ro` | `N/A` | Number of optimization steps. |
|
||||||
| horizon_hours | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
|
| horizon_hours | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
|
||||||
| individuals | `int | None` | `rw` | `300` | Number of individuals (solutions) in the population [>= 10]. Defaults to 300. |
|
| individuals | `Optional[int]` | `rw` | `300` | Number of individuals (solutions) in the population [>= 10]. Defaults to 300. |
|
||||||
| interval_sec | `int` | `rw` | `3600` | The optimization interval [sec]. Defaults to 3600 seconds (1 hour) |
|
| interval_sec | `int` | `rw` | `3600` | The optimization interval [sec]. Defaults to 3600 seconds (1 hour) |
|
||||||
| penalties | `dict[str, float | int | str]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
|
| penalties | `dict[str, Union[float, int, str]]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
|
||||||
| seed | `int | None` | `rw` | `None` | Random seed for reproducibility. None = random. |
|
| seed | `Optional[int]` | `rw` | `None` | Random seed for reproducibility. None = random. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| historic_hours | `EOS_PREDICTION__HISTORIC_HOURS` | `int | None` | `rw` | `48` | Number of hours into the past for historical predictions data |
|
| historic_hours | `EOS_PREDICTION__HISTORIC_HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the past for historical predictions data |
|
||||||
| hours | `EOS_PREDICTION__HOURS` | `int | None` | `rw` | `48` | Number of hours into the future for predictions |
|
| hours | `EOS_PREDICTION__HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the future for predictions |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| forecastsolar | `EOS_PVFORECAST__FORECASTSOLAR` | `PVForecastForecastSolarCommonSettings` | `rw` | `required` | ForecastSolar provider settings |
|
| forecastsolar | `EOS_PVFORECAST__FORECASTSOLAR` | `PVForecastForecastSolarCommonSettings` | `rw` | `required` | ForecastSolar provider settings |
|
||||||
| homeassistant | `EOS_PVFORECAST__HOMEASSISTANT` | `PVForecastHomeAssistantCommonSettings` | `rw` | `required` | Home Assistant provider settings |
|
| homeassistant | `EOS_PVFORECAST__HOMEASSISTANT` | `PVForecastHomeAssistantCommonSettings` | `rw` | `required` | Home Assistant provider settings |
|
||||||
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `int | None` | `rw` | `0` | Maximum number of planes that can be set |
|
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set |
|
||||||
| planes | `EOS_PVFORECAST__PLANES` | `list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting] | None` | `rw` | `None` | Plane configuration. |
|
| planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. |
|
||||||
| planes_azimuth | | `List[float]` | `ro` | `N/A` | Compute a list of the azimuths per active planes. |
|
| planes_azimuth | | `List[float]` | `ro` | `N/A` | Compute a list of the azimuths per active planes. |
|
||||||
| planes_inverter_paco | | `Any` | `ro` | `N/A` | Compute a list of the maximum power rating of the inverter per active planes. |
|
| planes_inverter_paco | | `Any` | `ro` | `N/A` | Compute a list of the maximum power rating of the inverter per active planes. |
|
||||||
| planes_peakpower | | `List[float]` | `ro` | `N/A` | Compute a list of the peak power per active planes. |
|
| planes_peakpower | | `List[float]` | `ro` | `N/A` | Compute a list of the peak power per active planes. |
|
||||||
| planes_tilt | | `List[float]` | `ro` | `N/A` | Compute a list of the tilts per active planes. |
|
| planes_tilt | | `List[float]` | `ro` | `N/A` | Compute a list of the tilts per active planes. |
|
||||||
| planes_userhorizon | | `Any` | `ro` | `N/A` | Compute a list of the user horizon per active planes. |
|
| planes_userhorizon | | `Any` | `ro` | `N/A` | Compute a list of the user horizon per active planes. |
|
||||||
| provider | `EOS_PVFORECAST__PROVIDER` | `str | None` | `rw` | `None` | PVForecast provider id of provider to be used. |
|
| provider | `EOS_PVFORECAST__PROVIDER` | `Optional[str]` | `rw` | `None` | PVForecast provider id of provider to be used. |
|
||||||
| providers | | `list[str]` | `ro` | `N/A` | Available PVForecast provider ids. |
|
| providers | | `list[str]` | `ro` | `N/A` | Available PVForecast provider ids. |
|
||||||
| pvforecastimport | `EOS_PVFORECAST__PVFORECASTIMPORT` | `PVForecastImportCommonSettings` | `rw` | `required` | PV forecast import provider settings |
|
| pvforecastimport | `EOS_PVFORECAST__PVFORECASTIMPORT` | `PVForecastImportCommonSettings` | `rw` | `required` | PV forecast import provider settings |
|
||||||
| pvlib | `EOS_PVFORECAST__PVLIB` | `PVForecastPVLibCommonSettings` | `rw` | `required` | PVLib provider settings |
|
| pvlib | `EOS_PVFORECAST__PVLIB` | `PVForecastPVLibCommonSettings` | `rw` | `required` | PVLib provider settings |
|
||||||
@@ -206,6 +206,7 @@
|
|||||||
"providers": [
|
"providers": [
|
||||||
"PVForecastAkkudoktor",
|
"PVForecastAkkudoktor",
|
||||||
"PVForecastForecastSolar",
|
"PVForecastForecastSolar",
|
||||||
|
"PVForecastHomeAssistant",
|
||||||
"PVForecastImport",
|
"PVForecastImport",
|
||||||
"PVForecastPVLib",
|
"PVForecastPVLib",
|
||||||
"PVForecastPVNode",
|
"PVForecastPVNode",
|
||||||
@@ -276,47 +277,6 @@
|
|||||||
```
|
```
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
### Common settings for pvforecast data from a Home Assistant entity
|
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
|
||||||
:::{table} pvforecast::homeassistant
|
|
||||||
:widths: 10 10 5 5 30
|
|
||||||
:align: left
|
|
||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
|
||||||
| entity_id | `str` | `rw` | `sensor.pv_forecast` | Home Assistant entity providing the PV forecast. |
|
|
||||||
| attribute | `str` | `rw` | `forecast` | Entity attribute holding the forecast list. |
|
|
||||||
| datetime_key | `str` | `rw` | `datetime` | Key for the timestamp in each forecast entry. |
|
|
||||||
| value_key | `str` | `rw` | `watts` | Key for the AC power value in each forecast entry. |
|
|
||||||
| value_unit | `Literal['W', 'kW']` | `rw` | `W` | Unit of the forecast value. Converted to W internally. |
|
|
||||||
| base_url | `str | None` | `rw` | `None` | Base URL of the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on (no SUPERVISOR_TOKEN available). |
|
|
||||||
| token | `str | None` | `rw` | `None` | Long-lived access token for the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on. |
|
|
||||||
:::
|
|
||||||
<!-- pyml enable line-length -->
|
|
||||||
|
|
||||||
<!-- pyml disable no-emphasis-as-heading -->
|
|
||||||
**Example Input/Output**
|
|
||||||
<!-- pyml enable no-emphasis-as-heading -->
|
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"pvforecast": {
|
|
||||||
"homeassistant": {
|
|
||||||
"entity_id": "sensor.pv_forecast",
|
|
||||||
"attribute": "forecast",
|
|
||||||
"datetime_key": "datetime",
|
|
||||||
"value_key": "watts",
|
|
||||||
"value_unit": "W",
|
|
||||||
"base_url": null,
|
|
||||||
"token": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
<!-- pyml enable line-length -->
|
|
||||||
|
|
||||||
### Common settings for the Solcast PV forecast provider
|
### Common settings for the Solcast PV forecast provider
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
<!-- pyml disable line-length -->
|
||||||
@@ -359,7 +319,7 @@
|
|||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| api_key | `str` | `rw` | `` | pvnode.com API key (Bearer auth). Required. |
|
| api_key | `str` | `rw` | `` | pvnode.com API key (Bearer auth). Required. |
|
||||||
| forecast_days | `int` | `rw` | `2` | Forecast horizon in days (1-7, capped by the pvnode plan). |
|
| forecast_days | `int` | `rw` | `2` | Forecast horizon in days (1-7, capped by the pvnode plan). |
|
||||||
| site_id | `str | None` | `rw` | `None` | pvnode.com site id of the saved plant ('Anlagen-ID'). When set, the saved (possibly calibrated) site is used. Leave empty to send the configured pvforecast.planes inline instead. |
|
| site_id | `Optional[str]` | `rw` | `None` | pvnode.com site id of the saved plant ('Anlagen-ID'). When set, the saved (possibly calibrated) site is used. Leave empty to send the configured pvforecast.planes inline instead. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -416,8 +376,8 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import PV forecast data from. |
|
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import PV forecast data from. |
|
||||||
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of PV forecast value lists. |
|
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of PV forecast value lists. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -447,22 +407,22 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| albedo | `float | None` | `rw` | `0.2` | Proportion of the light hitting the ground that it reflects back. |
|
| albedo | `Optional[float]` | `rw` | `0.2` | Proportion of the light hitting the ground that it reflects back. |
|
||||||
| inverter_model | `str | None` | `rw` | `None` | Model of the inverter of this plane. |
|
| inverter_model | `Optional[str]` | `rw` | `None` | Model of the inverter of this plane. |
|
||||||
| inverter_paco | `int | None` | `rw` | `None` | AC power rating of the inverter [W]. |
|
| inverter_paco | `Optional[int]` | `rw` | `None` | AC power rating of the inverter [W]. |
|
||||||
| loss | `float | None` | `rw` | `14.0` | Sum of PV system losses in percent |
|
| loss | `Optional[float]` | `rw` | `14.0` | Sum of PV system losses in percent |
|
||||||
| module_model | `str | None` | `rw` | `None` | Model of the PV modules of this plane. |
|
| module_model | `Optional[str]` | `rw` | `None` | Model of the PV modules of this plane. |
|
||||||
| modules_per_string | `int | None` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
|
| modules_per_string | `Optional[int]` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
|
||||||
| mountingplace | `str | None` | `rw` | `building` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
|
| mountingplace | `Optional[str]` | `rw` | `building` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
|
||||||
| optimal_surface_tilt | `bool | None` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
|
| optimal_surface_tilt | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
|
||||||
| optimalangles | `bool | None` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
|
| optimalangles | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
|
||||||
| peakpower | `float | None` | `rw` | `None` | Nominal power of PV system in kW. |
|
| peakpower | `Optional[float]` | `rw` | `None` | Nominal power of PV system in kW. |
|
||||||
| pvtechchoice | `str | None` | `rw` | `crystSi` | PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. |
|
| pvtechchoice | `Optional[str]` | `rw` | `crystSi` | PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. |
|
||||||
| strings_per_inverter | `int | None` | `rw` | `None` | Number of the strings of the inverter of this plane. |
|
| strings_per_inverter | `Optional[int]` | `rw` | `None` | Number of the strings of the inverter of this plane. |
|
||||||
| surface_azimuth | `float | None` | `rw` | `180.0` | Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). |
|
| surface_azimuth | `Optional[float]` | `rw` | `180.0` | Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). |
|
||||||
| surface_tilt | `float | None` | `rw` | `30.0` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
|
| surface_tilt | `Optional[float]` | `rw` | `30.0` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
|
||||||
| trackingtype | `int | None` | `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. |
|
| trackingtype | `Optional[int]` | `rw` | `None` | Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. |
|
||||||
| userhorizon | `List[float] | None` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
|
| userhorizon | `Optional[List[float]]` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
@@ -503,6 +463,47 @@
|
|||||||
```
|
```
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
### Common settings for pvforecast data from a Home Assistant entity
|
||||||
|
|
||||||
|
<!-- pyml disable line-length -->
|
||||||
|
:::{table} pvforecast::homeassistant
|
||||||
|
:widths: 10 10 5 5 30
|
||||||
|
:align: left
|
||||||
|
|
||||||
|
| Name | Type | Read-Only | Default | Description |
|
||||||
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
|
| attribute | `str` | `rw` | `forecast` | Entity attribute holding the forecast list. |
|
||||||
|
| base_url | `Optional[str]` | `rw` | `None` | Base URL of the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on (no SUPERVISOR_TOKEN available). |
|
||||||
|
| datetime_key | `str` | `rw` | `datetime` | Key for the timestamp in each forecast entry. |
|
||||||
|
| entity_id | `str` | `rw` | `sensor.pv_forecast` | Home Assistant entity providing the PV forecast. |
|
||||||
|
| token | `Optional[str]` | `rw` | `None` | Long-lived access token for the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on. |
|
||||||
|
| value_key | `str` | `rw` | `watts` | Key for the AC power value in each forecast entry. |
|
||||||
|
| value_unit | `Literal['W', 'kW']` | `rw` | `W` | Unit of the forecast value. Converted to W internally. |
|
||||||
|
:::
|
||||||
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
<!-- pyml disable no-emphasis-as-heading -->
|
||||||
|
**Example Input/Output**
|
||||||
|
<!-- pyml enable no-emphasis-as-heading -->
|
||||||
|
|
||||||
|
<!-- pyml disable line-length -->
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pvforecast": {
|
||||||
|
"homeassistant": {
|
||||||
|
"entity_id": "sensor.pv1_power_now",
|
||||||
|
"attribute": "forecast",
|
||||||
|
"datetime_key": "datetime",
|
||||||
|
"value_key": "watts",
|
||||||
|
"value_unit": "W",
|
||||||
|
"base_url": "http://homeassistant.local:8123",
|
||||||
|
"token": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
### Common settings for the Forecast.Solar PV forecast provider
|
### Common settings for the Forecast.Solar PV forecast provider
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
<!-- pyml disable line-length -->
|
||||||
@@ -512,7 +513,7 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| api_key | `str | None` | `rw` | `None` | Forecast.Solar API key. Optional — the public endpoint works without a key (lower rate limit). |
|
| api_key | `Optional[str]` | `rw` | `None` | Forecast.Solar API key. Optional — the public endpoint works without a key (lower rate limit). |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
| eosdash_supervise_interval_sec | `EOS_SERVER__EOSDASH_SUPERVISE_INTERVAL_SEC` | `int` | `rw` | `10` | Supervision interval for EOS server to supervise EOSdash [seconds]. |
|
| eosdash_supervise_interval_sec | `EOS_SERVER__EOSDASH_SUPERVISE_INTERVAL_SEC` | `int` | `rw` | `10` | Supervision interval for EOS server to supervise EOSdash [seconds]. |
|
||||||
| host | `EOS_SERVER__HOST` | `str` | `rw` | `127.0.0.1` | EOS server IP address. Defaults to 127.0.0.1. |
|
| host | `EOS_SERVER__HOST` | `str` | `rw` | `127.0.0.1` | EOS server IP address. Defaults to 127.0.0.1. |
|
||||||
| port | `EOS_SERVER__PORT` | `int` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. |
|
| port | `EOS_SERVER__PORT` | `int` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. |
|
||||||
| reload | `EOS_SERVER__RELOAD` | `bool | None` | `rw` | `False` | Enable server auto-reload for debugging or development. Default is False. Monitors the package directory for changes and reloads the server. |
|
| reload | `EOS_SERVER__RELOAD` | `Optional[bool]` | `rw` | `False` | Enable server auto-reload for debugging or development. Default is False. Monitors the package directory for changes and reloads the server. |
|
||||||
| run_as_user | `EOS_SERVER__RUN_AS_USER` | `str | None` | `rw` | `None` | The name of the target user to switch to. If ``None`` (default), the current effective user is used and no privilege change is attempted. |
|
| run_as_user | `EOS_SERVER__RUN_AS_USER` | `Optional[str]` | `rw` | `None` | The name of the target user to switch to. If ``None`` (default), the current effective user is used and no privilege change is attempted. |
|
||||||
| startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `bool | None` | `rw` | `True` | EOS server to start EOSdash server. Defaults to True. |
|
| startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `Optional[bool]` | `rw` | `True` | EOS server to start EOSdash server. Defaults to True. |
|
||||||
| verbose | `EOS_SERVER__VERBOSE` | `bool | None` | `rw` | `False` | Enable debug output |
|
| verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| provider | `EOS_WEATHER__PROVIDER` | `str | None` | `rw` | `None` | Weather provider id of provider to be used. |
|
| provider | `EOS_WEATHER__PROVIDER` | `Optional[str]` | `rw` | `None` | Weather provider id of provider to be used. |
|
||||||
| providers | | `list[str]` | `ro` | `N/A` | Available weather provider ids. |
|
| providers | | `list[str]` | `ro` | `N/A` | Available weather provider ids. |
|
||||||
| weatherimport | `EOS_WEATHER__WEATHERIMPORT` | `WeatherImportCommonSettings` | `rw` | `required` | Weather import provider settings |
|
| weatherimport | `EOS_WEATHER__WEATHERIMPORT` | `WeatherImportCommonSettings` | `rw` | `required` | Weather import provider settings |
|
||||||
:::
|
:::
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| Name | Type | Read-Only | Default | Description |
|
||||||
| ---- | ---- | --------- | ------- | ----------- |
|
| ---- | ---- | --------- | ------- | ----------- |
|
||||||
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import weather data from. |
|
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import weather data from. |
|
||||||
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of weather forecast value lists. |
|
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of weather forecast value lists. |
|
||||||
:::
|
:::
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Akkudoktor-EOS
|
# Akkudoktor-EOS
|
||||||
|
|
||||||
**Version**: `v0.3.0.dev2609011977897641`
|
**Version**: `v0.3.0.dev2609101507291966`
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
<!-- pyml disable line-length -->
|
||||||
**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.
|
**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.
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"name": "Apache 2.0",
|
"name": "Apache 2.0",
|
||||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||||
},
|
},
|
||||||
"version": "v0.3.0.dev2609011977897641"
|
"version": "v0.3.0.dev2609101507291966"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/v1/admin/cache/clear": {
|
"/v1/admin/cache/clear": {
|
||||||
|
|||||||
+16
-1
@@ -54,7 +54,7 @@ dev = [
|
|||||||
# - pre-commit-hooks
|
# - pre-commit-hooks
|
||||||
# - isort
|
# - isort
|
||||||
# - ruff
|
# - ruff
|
||||||
# - mypy (mirrors-mypy) - sync with requirements-dev.txt (if on pypi)
|
# Mypy runs in this development environment, using uv.lock, including from pre-commit.
|
||||||
# - pymarkdown
|
# - pymarkdown
|
||||||
# - commitizen - sync with requirements-dev.txt (if on pypi)
|
# - commitizen - sync with requirements-dev.txt (if on pypi)
|
||||||
#
|
#
|
||||||
@@ -66,6 +66,8 @@ dev = [
|
|||||||
"tokenize-rt==6.2.0", # for mypy
|
"tokenize-rt==6.2.0", # for mypy
|
||||||
"types-docutils==0.23.0.20260827", # for mypy
|
"types-docutils==0.23.0.20260827", # for mypy
|
||||||
"types-PyYaml==6.0.12.20260815", # for mypy
|
"types-PyYaml==6.0.12.20260815", # for mypy
|
||||||
|
"scipy-stubs==1.17.1.5", # for mypy
|
||||||
|
"types-psutil==7.2.2.20260906", # for mypy
|
||||||
"commitizen==4.18.0",
|
"commitizen==4.18.0",
|
||||||
"deprecated==1.3.1", # for commitizen
|
"deprecated==1.3.1", # for commitizen
|
||||||
|
|
||||||
@@ -168,12 +170,19 @@ filterwarnings = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
|
plugins = ["numpydantic.mypy", "pydantic.mypy"]
|
||||||
mypy_path= "src"
|
mypy_path= "src"
|
||||||
|
python_version = "3.13"
|
||||||
platform = "linux"
|
platform = "linux"
|
||||||
files = ["src", "tests"]
|
files = ["src", "tests"]
|
||||||
exclude = "class_soc_calc\\.py$"
|
exclude = "class_soc_calc\\.py$"
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
warn_unused_ignores = true
|
warn_unused_ignores = true
|
||||||
|
# Cached Pendulum analysis changes diagnostics between cold and warm runs with mypy 2.3.1.
|
||||||
|
incremental = false
|
||||||
|
|
||||||
|
[tool.pydantic-mypy]
|
||||||
|
init_typed = true
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "akkudoktoreos.*"
|
module = "akkudoktoreos.*"
|
||||||
@@ -191,6 +200,12 @@ ignore_missing_imports = true
|
|||||||
module = "xprocess.*"
|
module = "xprocess.*"
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
# These dependencies do not publish complete PEP 561 typing information.
|
||||||
|
# Keep their imports explicit; installed typed libraries and EOS code are checked.
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["cachebox", "fasthtml.*", "monsterui.*", "pvlib.*", "statsmodels.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
# Only used as linter
|
# Only used as linter
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Type, Union, get_args
|
from typing import Any, Optional, Type, TypeVar, Union, get_args
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
||||||
@@ -24,8 +24,8 @@ from akkudoktoreos.core.coreabc import get_config, singletons_init
|
|||||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||||
|
|
||||||
documented_types: set[PydanticBaseModel] = set()
|
documented_types: set[type[PydanticBaseModel]] = set()
|
||||||
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
|
undocumented_types: dict[type[PydanticBaseModel], tuple[str, list[str]]] = dict()
|
||||||
|
|
||||||
global_config_dict: dict[str, Any] = dict()
|
global_config_dict: dict[str, Any] = dict()
|
||||||
|
|
||||||
@@ -61,6 +61,9 @@ def get_body(config: type[PydanticBaseModel]) -> str:
|
|||||||
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
|
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
|
||||||
resolved_types: list[tuple[type, list[str]]] = []
|
resolved_types: list[tuple[type, list[str]]] = []
|
||||||
|
|
||||||
|
if isinstance(field_type, TypeVar):
|
||||||
|
field_type = field_type.__bound__ or Any
|
||||||
|
|
||||||
origin = getattr(field_type, "__origin__", field_type)
|
origin = getattr(field_type, "__origin__", field_type)
|
||||||
if origin is Union:
|
if origin is Union:
|
||||||
for arg in getattr(field_type, "__args__", []):
|
for arg in getattr(field_type, "__args__", []):
|
||||||
@@ -167,7 +170,7 @@ def build_nested_structure(keys: list[str], value: Any) -> Any:
|
|||||||
|
|
||||||
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
|
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
|
||||||
default_value = ""
|
default_value = ""
|
||||||
if regular_field:
|
if regular_field and isinstance(field_info, FieldInfo):
|
||||||
if (val := field_info.default) is not PydanticUndefined:
|
if (val := field_info.default) is not PydanticUndefined:
|
||||||
default_value = val
|
default_value = val
|
||||||
else:
|
else:
|
||||||
@@ -177,8 +180,13 @@ def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_f
|
|||||||
return default_value
|
return default_value
|
||||||
|
|
||||||
|
|
||||||
def get_type_name(field_type: type) -> str:
|
def get_type_name(field_type: Any) -> str:
|
||||||
type_name = str(field_type).replace("typing.", "").replace("pathlib._local", "pathlib")
|
type_name = str(field_type).replace("typing.", "").replace("pathlib._local", "pathlib")
|
||||||
|
# Unparameterized Pydantic generics validate against their TypeVar bound.
|
||||||
|
for arg in get_args(field_type):
|
||||||
|
if isinstance(arg, TypeVar) and isinstance(arg.__bound__, type):
|
||||||
|
bound = arg.__bound__
|
||||||
|
type_name = type_name.replace(str(arg), f"{bound.__module__}.{bound.__qualname__}")
|
||||||
if type_name.startswith("<class"):
|
if type_name.startswith("<class"):
|
||||||
type_name = field_type.__name__
|
type_name = field_type.__name__
|
||||||
return type_name
|
return type_name
|
||||||
@@ -229,17 +237,14 @@ def generate_config_table_md(
|
|||||||
table += f"| ---- {env_header_underline}| ---- | --------- | ------- | ----------- |\n"
|
table += f"| ---- {env_header_underline}| ---- | --------- | ------- | ----------- |\n"
|
||||||
|
|
||||||
|
|
||||||
fields = {}
|
fields: dict[str, FieldInfo | ComputedFieldInfo] = dict(config.model_fields)
|
||||||
for field_name, field_info in config.model_fields.items():
|
fields.update(config.model_computed_fields)
|
||||||
fields[field_name] = field_info
|
|
||||||
for field_name, field_info in config.model_computed_fields.items():
|
|
||||||
fields[field_name] = field_info
|
|
||||||
for field_name in sorted(fields.keys()):
|
for field_name in sorted(fields.keys()):
|
||||||
field_info = fields[field_name]
|
field_info = fields[field_name]
|
||||||
regular_field = isinstance(field_info, FieldInfo)
|
regular_field = isinstance(field_info, FieldInfo)
|
||||||
|
|
||||||
config_name = field_name if extra_config else field_name.upper()
|
config_name = field_name if extra_config else field_name.upper()
|
||||||
field_type = field_info.annotation if regular_field else field_info.return_type
|
field_type = (field_info.annotation if isinstance(field_info, FieldInfo) else field_info.return_type)
|
||||||
default_value = get_default_value(field_info, regular_field)
|
default_value = get_default_value(field_info, regular_field)
|
||||||
description = config.field_description(field_name)
|
description = config.field_description(field_name)
|
||||||
deprecated = config.field_deprecated(field_name)
|
deprecated = config.field_deprecated(field_name)
|
||||||
@@ -462,6 +467,8 @@ def write_to_file(file_path: Optional[Union[str, Path]], config_md: str):
|
|||||||
|
|
||||||
# Assure timezone name does not leak to documentation
|
# Assure timezone name does not leak to documentation
|
||||||
tz_name = to_datetime().timezone_name
|
tz_name = to_datetime().timezone_name
|
||||||
|
if tz_name is None:
|
||||||
|
raise RuntimeError("Documentation generation requires a timezone name")
|
||||||
config_md = re.sub(re.escape(tz_name), "Europe/Berlin", config_md, flags=re.IGNORECASE)
|
config_md = re.sub(re.escape(tz_name), "Europe/Berlin", config_md, flags=re.IGNORECASE)
|
||||||
# Also replace UTC, as GitHub CI always is on UTC
|
# Also replace UTC, as GitHub CI always is on UTC
|
||||||
config_md = re.sub(re.escape("UTC"), "Europe/Berlin", config_md, flags=re.IGNORECASE)
|
config_md = re.sub(re.escape("UTC"), "Europe/Berlin", config_md, flags=re.IGNORECASE)
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import git
|
import git
|
||||||
|
|
||||||
if __package__ is None or __package__ == "":
|
if TYPE_CHECKING:
|
||||||
|
from . import generate_openapi
|
||||||
|
elif __package__ is None or __package__ == "":
|
||||||
# uses current directory visibility
|
# uses current directory visibility
|
||||||
import generate_openapi
|
import generate_openapi
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ def adapter_providers() -> list[Union["HomeAssistantAdapter", "NodeREDAdapter"]]
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class Adapter(AdapterContainer):
|
class Adapter(AdapterContainer[HomeAssistantAdapter | NodeREDAdapter]):
|
||||||
"""Adapter container to manage multiple adapter providers."""
|
"""Adapter container to manage multiple adapter providers."""
|
||||||
|
|
||||||
providers: list[
|
providers: list[
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import Any, Optional
|
from typing import Any, Generic, Optional, TypeVar
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
@@ -102,18 +102,21 @@ class AdapterProvider(SingletonMixin, ConfigMixin, MeasurementMixin, StartMixin,
|
|||||||
await self._update_data()
|
await self._update_data()
|
||||||
|
|
||||||
|
|
||||||
class AdapterContainer(SingletonMixin, ConfigMixin, PydanticBaseModel):
|
AdapterProviderT = TypeVar("AdapterProviderT", bound=AdapterProvider)
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterContainer(SingletonMixin, ConfigMixin, PydanticBaseModel, Generic[AdapterProviderT]):
|
||||||
"""A container for managing multiple adapter provider instances.
|
"""A container for managing multiple adapter provider instances.
|
||||||
|
|
||||||
This class enables to control multiple adapter providers
|
This class enables to control multiple adapter providers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
providers: list[AdapterProvider] = Field(
|
providers: list[AdapterProviderT] = Field(
|
||||||
default_factory=list, json_schema_extra={"description": "List of adapter providers"}
|
default_factory=list, json_schema_extra={"description": "List of adapter providers"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@field_validator("providers")
|
@field_validator("providers")
|
||||||
def check_providers(cls, value: list[AdapterProvider]) -> list[AdapterProvider]:
|
def check_providers(cls, value: list[AdapterProviderT]) -> list[AdapterProviderT]:
|
||||||
# Check each item in the list
|
# Check each item in the list
|
||||||
for item in value:
|
for item in value:
|
||||||
if not isinstance(item, AdapterProvider):
|
if not isinstance(item, AdapterProvider):
|
||||||
@@ -149,7 +152,7 @@ class AdapterContainer(SingletonMixin, ConfigMixin, PydanticBaseModel):
|
|||||||
return
|
return
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def provider_by_id(self, provider_id: str) -> AdapterProvider:
|
def provider_by_id(self, provider_id: str) -> AdapterProviderT:
|
||||||
"""Retrieves an adapter provider by its unique identifier.
|
"""Retrieves an adapter provider by its unique identifier.
|
||||||
|
|
||||||
This method searches through the list of all available providers and
|
This method searches through the list of all available providers and
|
||||||
|
|||||||
@@ -145,7 +145,13 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
|
|||||||
"""Entity IDs available at Home Assistant."""
|
"""Entity IDs available at Home Assistant."""
|
||||||
try:
|
try:
|
||||||
adapter_eos = get_adapter()
|
adapter_eos = get_adapter()
|
||||||
result = adapter_eos.provider_by_id("HomeAssistant").get_homeassistant_entity_ids()
|
provider = adapter_eos.provider_by_id("HomeAssistant")
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not isinstance(provider, HomeAssistantAdapter):
|
||||||
|
raise TypeError("HomeAssistant provider must be a HomeAssistantAdapter")
|
||||||
|
try:
|
||||||
|
result = provider.get_homeassistant_entity_ids()
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
return result
|
return result
|
||||||
@@ -156,7 +162,13 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
|
|||||||
"""Entity IDs for optimization solution available at EOS."""
|
"""Entity IDs for optimization solution available at EOS."""
|
||||||
try:
|
try:
|
||||||
adapter_eos = get_adapter()
|
adapter_eos = get_adapter()
|
||||||
result = adapter_eos.provider_by_id("HomeAssistant").get_eos_solution_entity_ids()
|
provider = adapter_eos.provider_by_id("HomeAssistant")
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not isinstance(provider, HomeAssistantAdapter):
|
||||||
|
raise TypeError("HomeAssistant provider must be a HomeAssistantAdapter")
|
||||||
|
try:
|
||||||
|
result = provider.get_eos_solution_entity_ids()
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
return result
|
return result
|
||||||
@@ -167,9 +179,13 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
|
|||||||
"""Entity IDs for energy management instructions available at EOS."""
|
"""Entity IDs for energy management instructions available at EOS."""
|
||||||
try:
|
try:
|
||||||
adapter_eos = get_adapter()
|
adapter_eos = get_adapter()
|
||||||
result = adapter_eos.provider_by_id(
|
provider = adapter_eos.provider_by_id("HomeAssistant")
|
||||||
"HomeAssistant"
|
except Exception:
|
||||||
).get_eos_device_instruction_entity_ids()
|
return []
|
||||||
|
if not isinstance(provider, HomeAssistantAdapter):
|
||||||
|
raise TypeError("HomeAssistant provider must be a HomeAssistantAdapter")
|
||||||
|
try:
|
||||||
|
result = provider.get_eos_device_instruction_entity_ids()
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, ClassVar, Optional, Type, Union
|
from typing import Any, Callable, ClassVar, Optional, Type, Union
|
||||||
|
|
||||||
import pydantic_settings
|
import pydantic_settings
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -122,6 +122,10 @@ def default_data_folder_path() -> Path:
|
|||||||
class GeneralSettings(SettingsBaseModel):
|
class GeneralSettings(SettingsBaseModel):
|
||||||
"""General settings."""
|
"""General settings."""
|
||||||
|
|
||||||
|
# Legacy configuration-path metadata populated by ConfigEOS._setup_config_file.
|
||||||
|
_config_file_path: ClassVar[Path | None] = None
|
||||||
|
_config_folder_path: ClassVar[Path | None] = None
|
||||||
|
|
||||||
config_save_mode: ConfigSaveMode = Field(
|
config_save_mode: ConfigSaveMode = Field(
|
||||||
default=ConfigSaveMode.AUTOMATIC,
|
default=ConfigSaveMode.AUTOMATIC,
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
@@ -161,8 +165,11 @@ class GeneralSettings(SettingsBaseModel):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate this raw default to Path. Retain the string so
|
||||||
|
# exclude_defaults preserves the output path in migrated configurations.
|
||||||
data_output_subpath: Optional[Path] = Field(
|
data_output_subpath: Optional[Path] = Field(
|
||||||
default="output",
|
default="output",
|
||||||
|
validate_default=True,
|
||||||
json_schema_extra={"description": "Sub-path for the EOS output data folder."},
|
json_schema_extra={"description": "Sub-path for the EOS output data folder."},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -402,14 +409,15 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def settings_customise_sources(
|
# Pydantic Settings accepts zero-argument callables as well as source objects.
|
||||||
|
def settings_customise_sources( # type: ignore[override]
|
||||||
cls,
|
cls,
|
||||||
settings_cls: Type[pydantic_settings.BaseSettings],
|
settings_cls: Type[pydantic_settings.BaseSettings],
|
||||||
init_settings: pydantic_settings.PydanticBaseSettingsSource,
|
init_settings: pydantic_settings.PydanticBaseSettingsSource,
|
||||||
env_settings: pydantic_settings.PydanticBaseSettingsSource,
|
env_settings: pydantic_settings.PydanticBaseSettingsSource,
|
||||||
dotenv_settings: pydantic_settings.PydanticBaseSettingsSource,
|
dotenv_settings: pydantic_settings.PydanticBaseSettingsSource,
|
||||||
file_secret_settings: pydantic_settings.PydanticBaseSettingsSource,
|
file_secret_settings: pydantic_settings.PydanticBaseSettingsSource,
|
||||||
) -> tuple[pydantic_settings.PydanticBaseSettingsSource, ...]:
|
) -> tuple[pydantic_settings.PydanticBaseSettingsSource | Callable[[], dict[str, Any]], ...]:
|
||||||
"""Customizes the order and handling of settings sources for a pydantic_settings.BaseSettings subclass.
|
"""Customizes the order and handling of settings sources for a pydantic_settings.BaseSettings subclass.
|
||||||
|
|
||||||
This method determines the sources for application configuration settings, including
|
This method determines the sources for application configuration settings, including
|
||||||
@@ -790,7 +798,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
required by ``self._setup()``.
|
required by ``self._setup()``.
|
||||||
OSError: If reading the backup file fails due to I/O issues.
|
OSError: If reading the backup file fails due to I/O issues.
|
||||||
"""
|
"""
|
||||||
backup_file_path = self.general.config_file_path.with_suffix(f".{backup_id}")
|
config_file_path = self.general.config_file_path
|
||||||
|
# Configuration setup initializes this path; should never raise.
|
||||||
|
if config_file_path is None:
|
||||||
|
raise AssertionError("Configuration file path is not initialized")
|
||||||
|
backup_file_path = config_file_path.with_suffix(f".{backup_id}")
|
||||||
if not backup_file_path.exists():
|
if not backup_file_path.exists():
|
||||||
error_msg = f"Configuration backup `{backup_id}` not found."
|
error_msg = f"Configuration backup `{backup_id}` not found."
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
@@ -823,7 +835,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
"""
|
"""
|
||||||
result: dict[str, dict[str, Any]] = {}
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
base_path: Path = self.general.config_file_path
|
base_path = self.general.config_file_path
|
||||||
|
# Configuration setup initializes this path; should never raise.
|
||||||
|
if base_path is None:
|
||||||
|
raise AssertionError("Configuration file path is not initialized")
|
||||||
parent = base_path.parent
|
parent = base_path.parent
|
||||||
stem = base_path.stem
|
stem = base_path.stem
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import calendar
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Any, ClassVar, Iterator, Optional, Union
|
from typing import Any, ClassVar, Generic, Iterator, Optional, TypeVar, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -409,19 +409,23 @@ class TimeWindow(SettingsBaseModel):
|
|||||||
return self.duration
|
return self.duration
|
||||||
|
|
||||||
|
|
||||||
class TimeWindowSequence(SettingsBaseModel):
|
TimeWindowT = TypeVar("TimeWindowT", bound=TimeWindow)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeWindowSequence(SettingsBaseModel, Generic[TimeWindowT]):
|
||||||
"""Model representing a sequence of time windows with collective operations.
|
"""Model representing a sequence of time windows with collective operations.
|
||||||
|
|
||||||
Manages multiple TimeWindow objects and provides methods to work with them
|
Manages multiple TimeWindow objects and provides methods to work with them
|
||||||
as a cohesive unit for scheduling and availability checking.
|
as a cohesive unit for scheduling and availability checking.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
windows: list[TimeWindow] = Field(
|
windows: list[TimeWindowT] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
json_schema_extra={"description": "List of TimeWindow objects that make up this sequence."},
|
json_schema_extra={"description": "List of TimeWindow objects that make up this sequence."},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[TimeWindow]:
|
# EOS collections iterate over their elements instead of BaseModel field/value pairs.
|
||||||
|
def __iter__(self) -> Iterator[TimeWindowT]: # type: ignore[override]
|
||||||
"""Allow iteration over the time windows."""
|
"""Allow iteration over the time windows."""
|
||||||
return iter(self.windows)
|
return iter(self.windows)
|
||||||
|
|
||||||
@@ -429,7 +433,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
|||||||
"""Return the number of time windows in the sequence."""
|
"""Return the number of time windows in the sequence."""
|
||||||
return len(self.windows)
|
return len(self.windows)
|
||||||
|
|
||||||
def __getitem__(self, index: int) -> TimeWindow:
|
def __getitem__(self, index: int) -> TimeWindowT:
|
||||||
"""Allow indexing into the time windows."""
|
"""Allow indexing into the time windows."""
|
||||||
return self.windows[index]
|
return self.windows[index]
|
||||||
|
|
||||||
@@ -536,7 +540,9 @@ class TimeWindowSequence(SettingsBaseModel):
|
|||||||
total += d
|
total += d
|
||||||
return total
|
return total
|
||||||
|
|
||||||
def get_applicable_windows(self, reference_date: Optional[DateTime] = None) -> list[TimeWindow]:
|
def get_applicable_windows(
|
||||||
|
self, reference_date: Optional[DateTime] = None
|
||||||
|
) -> list[TimeWindowT]:
|
||||||
"""Get all windows that apply to the given reference date.
|
"""Get all windows that apply to the given reference date.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -556,7 +562,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
|||||||
|
|
||||||
def find_windows_for_duration(
|
def find_windows_for_duration(
|
||||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||||
) -> list[TimeWindow]:
|
) -> list[TimeWindowT]:
|
||||||
"""Find all windows that can accommodate the given duration.
|
"""Find all windows that can accommodate the given duration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -575,7 +581,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
|||||||
|
|
||||||
def get_all_possible_start_times(
|
def get_all_possible_start_times(
|
||||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||||
) -> list[tuple[DateTime, DateTime, TimeWindow]]:
|
) -> list[tuple[DateTime, DateTime, TimeWindowT]]:
|
||||||
"""Get all possible start time ranges for a duration across all windows.
|
"""Get all possible start time ranges for a duration across all windows.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -739,7 +745,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
|||||||
dtype=np.float64,
|
dtype=np.float64,
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_window(self, window: TimeWindow) -> None:
|
def add_window(self, window: TimeWindowT) -> None:
|
||||||
"""Add a new time window to the sequence.
|
"""Add a new time window to the sequence.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -747,7 +753,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
|||||||
"""
|
"""
|
||||||
self.windows.append(window)
|
self.windows.append(window)
|
||||||
|
|
||||||
def remove_window(self, index: int) -> TimeWindow:
|
def remove_window(self, index: int) -> TimeWindowT:
|
||||||
"""Remove a time window from the sequence by index.
|
"""Remove a time window from the sequence by index.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -781,7 +787,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
|||||||
if reference_date is None:
|
if reference_date is None:
|
||||||
reference_date = pendulum.today()
|
reference_date = pendulum.today()
|
||||||
|
|
||||||
def sort_key(window: TimeWindow) -> tuple[int, DateTime]:
|
def sort_key(window: TimeWindowT) -> tuple[int, DateTime]:
|
||||||
start_time = window.earliest_start_time(Duration(), reference_date)
|
start_time = window.earliest_start_time(Duration(), reference_date)
|
||||||
if start_time is None:
|
if start_time is None:
|
||||||
return (1, reference_date)
|
return (1, reference_date)
|
||||||
@@ -806,7 +812,7 @@ class ValueTimeWindow(TimeWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ValueTimeWindowSequence(TimeWindowSequence):
|
class ValueTimeWindowSequence(TimeWindowSequence[ValueTimeWindow]):
|
||||||
"""Sequence of value time windows.
|
"""Sequence of value time windows.
|
||||||
|
|
||||||
This model specializes `TimeWindowSequence` to ensure that all
|
This model specializes `TimeWindowSequence` to ensure that all
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from typing import (
|
|||||||
Optional,
|
Optional,
|
||||||
ParamSpec,
|
ParamSpec,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
import cachebox
|
import cachebox
|
||||||
@@ -46,7 +47,8 @@ from akkudoktoreos.utils.datetimeutil import (
|
|||||||
# ---------------------------------
|
# ---------------------------------
|
||||||
|
|
||||||
# Define a type variable for methods and functions
|
# Define a type variable for methods and functions
|
||||||
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
|
Param = ParamSpec("Param")
|
||||||
|
RetType = TypeVar("RetType")
|
||||||
|
|
||||||
|
|
||||||
def cache_energy_management_store_callback(event: int, key: Any, value: Any) -> None:
|
def cache_energy_management_store_callback(event: int, key: Any, value: Any) -> None:
|
||||||
@@ -195,7 +197,9 @@ class CacheEnergyManagementStore(SingletonMixin):
|
|||||||
raise AttributeError(f"'{self.cache.__class__.__name__}' object has no method 'clear'")
|
raise AttributeError(f"'{self.cache.__class__.__name__}' object has no method 'clear'")
|
||||||
|
|
||||||
|
|
||||||
def cache_energy_management(callable: TCallable) -> TCallable:
|
def cache_energy_management(
|
||||||
|
func: Callable[Param, RetType],
|
||||||
|
) -> Callable[Param, RetType]:
|
||||||
"""Decorator for in memory caching the result of a callable.
|
"""Decorator for in memory caching the result of a callable.
|
||||||
|
|
||||||
This decorator caches the method or function's result in `CacheEnergyManagementStore`,
|
This decorator caches the method or function's result in `CacheEnergyManagementStore`,
|
||||||
@@ -203,7 +207,7 @@ def cache_energy_management(callable: TCallable) -> TCallable:
|
|||||||
next energy management start.
|
next energy management start.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
callable (Callable): The function or method to be decorated.
|
func (Callable): The function or method to be decorated.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Callable: The wrapped function with caching functionality.
|
Callable: The wrapped function with caching functionality.
|
||||||
@@ -218,24 +222,22 @@ def cache_energy_management(callable: TCallable) -> TCallable:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@cachebox.cached(
|
@functools.wraps(func)
|
||||||
cache=CacheEnergyManagementStore().cache, callback=cache_energy_management_store_callback
|
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> RetType:
|
||||||
)
|
return func(*args, **kwargs)
|
||||||
@functools.wraps(callable)
|
|
||||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
result = callable(*args, **kwargs)
|
|
||||||
return result
|
|
||||||
|
|
||||||
return wrapper
|
cached_wrapper = cachebox.cached(
|
||||||
|
cache=CacheEnergyManagementStore().cache,
|
||||||
|
callback=cache_energy_management_store_callback,
|
||||||
|
)(wrapper)
|
||||||
|
|
||||||
|
return cast(Callable[Param, RetType], cached_wrapper)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------
|
# ---------------------------------
|
||||||
# Cache File Management
|
# Cache File Management
|
||||||
# ---------------------------------
|
# ---------------------------------
|
||||||
|
|
||||||
Param = ParamSpec("Param")
|
|
||||||
RetType = TypeVar("RetType")
|
|
||||||
|
|
||||||
|
|
||||||
def cache_clear(clear_all: Optional[bool] = None) -> None:
|
def cache_clear(clear_all: Optional[bool] = None) -> None:
|
||||||
"""Cleanup expired cache files."""
|
"""Cleanup expired cache files."""
|
||||||
@@ -742,6 +744,9 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
if clear_all:
|
if clear_all:
|
||||||
clear_file = True
|
clear_file = True
|
||||||
else:
|
else:
|
||||||
|
# Initialized above when clear_all is false; should never raise.
|
||||||
|
if before_datetime is None:
|
||||||
|
raise AssertionError("Cache expiry threshold is not initialized")
|
||||||
clear_file = compare_datetimes(cache_item.until_datetime, before_datetime).lt
|
clear_file = compare_datetimes(cache_item.until_datetime, before_datetime).lt
|
||||||
|
|
||||||
if clear_file:
|
if clear_file:
|
||||||
@@ -782,9 +787,11 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
|||||||
with self._store_lock:
|
with self._store_lock:
|
||||||
store_current = {}
|
store_current = {}
|
||||||
for key, record in self._store.items():
|
for key, record in self._store.items():
|
||||||
ttl_duration = record.ttl_duration
|
ttl_duration = (
|
||||||
if ttl_duration:
|
record.ttl_duration.total_seconds()
|
||||||
ttl_duration = ttl_duration.total_seconds()
|
if record.ttl_duration
|
||||||
|
else record.ttl_duration
|
||||||
|
)
|
||||||
store_current[key] = {
|
store_current[key] = {
|
||||||
# Convert file-like objects to file paths for serialization
|
# Convert file-like objects to file paths for serialization
|
||||||
"cache_file": self._get_file_path(record.cache_file),
|
"cache_file": self._get_file_path(record.cache_file),
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ from akkudoktoreos.config.configabc import SettingsBaseModel
|
|||||||
class CacheCommonSettings(SettingsBaseModel):
|
class CacheCommonSettings(SettingsBaseModel):
|
||||||
"""Cache Configuration."""
|
"""Cache Configuration."""
|
||||||
|
|
||||||
|
# Retain the raw serialized default for exclude_defaults compatibility.
|
||||||
subpath: Optional[Path] = Field(
|
subpath: Optional[Path] = Field(
|
||||||
default="cache",
|
default="cache",
|
||||||
|
validate_default=True,
|
||||||
json_schema_extra={"description": "Sub-path for the EOS cache data directory."},
|
json_schema_extra={"description": "Sub-path for the EOS cache data directory."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ from typing import (
|
|||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
Dict,
|
Dict,
|
||||||
|
Generic,
|
||||||
Iterator,
|
Iterator,
|
||||||
Optional,
|
Optional,
|
||||||
Tuple,
|
Tuple,
|
||||||
Type,
|
Type,
|
||||||
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
|
cast,
|
||||||
get_args,
|
get_args,
|
||||||
overload,
|
overload,
|
||||||
)
|
)
|
||||||
@@ -289,7 +292,8 @@ class DataRecord(DataABC, MutableMapping):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise KeyError(f"'{key}' is not a recognized field.")
|
raise KeyError(f"'{key}' is not a recognized field.")
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[str]:
|
# EOS collections iterate over their elements instead of BaseModel field/value pairs.
|
||||||
|
def __iter__(self) -> Iterator[str]: # type: ignore[override]
|
||||||
"""Iterate over the field names in the data record.
|
"""Iterate over the field names in the data record.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -441,7 +445,10 @@ class DataRecord(DataABC, MutableMapping):
|
|||||||
# ==================== DataSequence ====================
|
# ==================== DataSequence ====================
|
||||||
|
|
||||||
|
|
||||||
class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
DataRecordT = TypeVar("DataRecordT", bound=DataRecord)
|
||||||
|
|
||||||
|
|
||||||
|
class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecordT], Generic[DataRecordT]):
|
||||||
"""A managed sequence of DataRecord instances with time series behavior.
|
"""A managed sequence of DataRecord instances with time series behavior.
|
||||||
|
|
||||||
The DataSequence class provides an ordered, mutable collection of DataRecord
|
The DataSequence class provides an ordered, mutable collection of DataRecord
|
||||||
@@ -488,7 +495,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# To be overloaded by derived classes.
|
# To be overloaded by derived classes.
|
||||||
records: list[DataRecord] = Field(
|
records: list[DataRecordT] = Field(
|
||||||
default_factory=list, json_schema_extra={"description": "List of data records"}
|
default_factory=list, json_schema_extra={"description": "List of data records"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -553,7 +560,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
f"Key '{key}' is not in writable record keys: {self.record_keys_writable}"
|
f"Key '{key}' is not in writable record keys: {self.record_keys_writable}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _validate_record(self, value: DataRecord) -> None:
|
def _validate_record(self, value: DataRecordT) -> None:
|
||||||
"""Check if the provided value is a valid DataRecord with compatible keys.
|
"""Check if the provided value is a valid DataRecord with compatible keys.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -629,7 +636,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
return self.record_class().record_keys_writable()
|
return self.record_class().record_keys_writable()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def record_class(cls) -> Type:
|
def record_class(cls) -> Type[DataRecordT]:
|
||||||
"""Get the class of the data record handled by this data sequence.
|
"""Get the class of the data record handled by this data sequence.
|
||||||
|
|
||||||
This method determines the class of the data record type associated with
|
This method determines the class of the data record type associated with
|
||||||
@@ -646,6 +653,8 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
field_info = cls.model_fields["records"]
|
field_info = cls.model_fields["records"]
|
||||||
# Get the list element type from the 'type_' attribute
|
# Get the list element type from the 'type_' attribute
|
||||||
list_element_type = get_args(field_info.annotation)[0]
|
list_element_type = get_args(field_info.annotation)[0]
|
||||||
|
if isinstance(list_element_type, TypeVar):
|
||||||
|
list_element_type = list_element_type.__bound__
|
||||||
if not isinstance(list_element_type(), DataRecord):
|
if not isinstance(list_element_type(), DataRecord):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Data record must be an instance of DataRecord: '{list_element_type}'."
|
f"Data record must be an instance of DataRecord: '{list_element_type}'."
|
||||||
@@ -736,17 +745,18 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
|
|
||||||
# Sequence methods
|
# Sequence methods
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[DataRecord]:
|
# EOS collections iterate over their elements instead of BaseModel field/value pairs.
|
||||||
|
def __iter__(self) -> Iterator[DataRecordT]: # type: ignore[override]
|
||||||
"""Create an iterator for accessing DataRecords sequentially (memory only).
|
"""Create an iterator for accessing DataRecords sequentially (memory only).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Iterator[DataRecord]: An iterator for the records.
|
Iterator[DataRecordT]: An iterator for the records.
|
||||||
"""
|
"""
|
||||||
return iter(self.records)
|
return iter(self.records)
|
||||||
|
|
||||||
async def get_by_datetime(
|
async def get_by_datetime(
|
||||||
self, target_datetime: DateTime, *, time_window: Optional[Duration] = None
|
self, target_datetime: DateTime, *, time_window: Optional[Duration] = None
|
||||||
) -> Optional[DataRecord]:
|
) -> Optional[DataRecordT]:
|
||||||
"""Get the record at the specified datetime, with an optional fallback search window.
|
"""Get the record at the specified datetime, with an optional fallback search window.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -770,7 +780,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
|
|
||||||
async def get_nearest_by_datetime(
|
async def get_nearest_by_datetime(
|
||||||
self, target_datetime: DateTime, time_window: Optional[Duration] = None
|
self, target_datetime: DateTime, time_window: Optional[Duration] = None
|
||||||
) -> Optional[DataRecord]:
|
) -> Optional[DataRecordT]:
|
||||||
"""Get the record nearest to the specified datetime within an optional time window.
|
"""Get the record nearest to the specified datetime within an optional time window.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -800,7 +810,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
|
|
||||||
# sync rw write access to data sequence, needs locking in case of use in async.
|
# sync rw write access to data sequence, needs locking in case of use in async.
|
||||||
|
|
||||||
async def _insert_by_datetime(self, record: DataRecord) -> None:
|
async def _insert_by_datetime(self, record: DataRecordT) -> None:
|
||||||
"""Insert or merge a DataRecord into the sequence based on its datetime.
|
"""Insert or merge a DataRecord into the sequence based on its datetime.
|
||||||
|
|
||||||
Internal implementation of `insert_by_datetime`. Callers must
|
Internal implementation of `insert_by_datetime`. Callers must
|
||||||
@@ -822,8 +832,10 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
"""
|
"""
|
||||||
self._validate_record(record)
|
self._validate_record(record)
|
||||||
|
|
||||||
# Ensure datetime objects are normalized
|
# _validate_record normalizes the timestamp, including a missing value.
|
||||||
record_date_time_timestamp = DatabaseTimestamp.from_datetime(record.date_time)
|
record_date_time_timestamp = DatabaseTimestamp.from_datetime(
|
||||||
|
self._db_require_date_time(record)
|
||||||
|
)
|
||||||
|
|
||||||
avail_record = await self.db_get_record(record_date_time_timestamp)
|
avail_record = await self.db_get_record(record_date_time_timestamp)
|
||||||
if avail_record:
|
if avail_record:
|
||||||
@@ -914,7 +926,9 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
avail_record = await self.db_get_record(db_target)
|
avail_record = await self.db_get_record(db_target)
|
||||||
if avail_record is None:
|
if avail_record is None:
|
||||||
# Create a new DataRecord if none exists
|
# Create a new DataRecord if none exists
|
||||||
new_record = self.record_class()(date_time=date_time, **{key: values[i]})
|
new_record = self.record_class().model_validate(
|
||||||
|
{"date_time": date_time, key: values[i]}
|
||||||
|
)
|
||||||
await self.db_insert_record(new_record)
|
await self.db_insert_record(new_record)
|
||||||
else:
|
else:
|
||||||
# Update existing record's specified key
|
# Update existing record's specified key
|
||||||
@@ -949,7 +963,9 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
avail_record = await self.db_get_record(db_target)
|
avail_record = await self.db_get_record(db_target)
|
||||||
if avail_record is None:
|
if avail_record is None:
|
||||||
# Create a new DataRecord if none exists
|
# Create a new DataRecord if none exists
|
||||||
new_record = self.record_class()(date_time=date_time, **{key: value})
|
new_record = self.record_class().model_validate(
|
||||||
|
{"date_time": date_time, key: value}
|
||||||
|
)
|
||||||
await self.db_insert_record(new_record)
|
await self.db_insert_record(new_record)
|
||||||
else:
|
else:
|
||||||
# Update existing record's specified key
|
# Update existing record's specified key
|
||||||
@@ -958,7 +974,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
|
|
||||||
# data sequence access usable also for async access
|
# data sequence access usable also for async access
|
||||||
|
|
||||||
async def insert_by_datetime(self, record: DataRecord) -> None:
|
async def insert_by_datetime(self, record: DataRecordT) -> None:
|
||||||
"""Insert or merge a DataRecord into the sequence based on its date.
|
"""Insert or merge a DataRecord into the sequence based on its date.
|
||||||
|
|
||||||
If a record with the same date exists, merges new data fields with the existing record.
|
If a record with the same date exists, merges new data fields with the existing record.
|
||||||
@@ -1010,7 +1026,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
start_datetime: Optional[DateTime] = None,
|
start_datetime: Optional[DateTime] = None,
|
||||||
end_datetime: Optional[DateTime] = None,
|
end_datetime: Optional[DateTime] = None,
|
||||||
dropna: bool = True,
|
dropna: bool = True,
|
||||||
) -> Dict[DateTime, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Extract a dictionary indexed by the date_time field of the DataRecords.
|
"""Extract a dictionary indexed by the date_time field of the DataRecords.
|
||||||
|
|
||||||
The dictionary will contain values extracted from the specified key attribute of each DataRecord,
|
The dictionary will contain values extracted from the specified key attribute of each DataRecord,
|
||||||
@@ -1130,7 +1146,8 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
end_timestamp is None or record_date_time_timestamp < end_timestamp
|
end_timestamp is None or record_date_time_timestamp < end_timestamp
|
||||||
):
|
):
|
||||||
filtered_records.append(record)
|
filtered_records.append(record)
|
||||||
dates = [record.date_time for record in filtered_records]
|
# The filter above already excludes records without timestamps.
|
||||||
|
dates = cast(list[DateTime], [record.date_time for record in filtered_records])
|
||||||
values = [getattr(record, key, None) for record in filtered_records]
|
values = [getattr(record, key, None) for record in filtered_records]
|
||||||
|
|
||||||
return dates, values
|
return dates, values
|
||||||
@@ -1310,7 +1327,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
query_start = DatabaseTimestamp.to_datetime(query_start_timestamp)
|
query_start = DatabaseTimestamp.to_datetime(query_start_timestamp)
|
||||||
if end_datetime is not None:
|
if end_datetime is not None:
|
||||||
# We have a end datetime - look for next entry
|
# We have a end datetime - look for next entry
|
||||||
end_timestamp = DatabaseTimestamp.from_datetime(query_end)
|
end_timestamp = DatabaseTimestamp.from_datetime(end_datetime)
|
||||||
query_end_timestamp = await self.db_next_timestamp(end_timestamp)
|
query_end_timestamp = await self.db_next_timestamp(end_timestamp)
|
||||||
if query_end_timestamp is None:
|
if query_end_timestamp is None:
|
||||||
# Ensure at least end_datetime is included (excluded by definition)
|
# Ensure at least end_datetime is included (excluded by definition)
|
||||||
@@ -1382,13 +1399,12 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
floored_epoch, unit="s", tz="UTC"
|
floored_epoch, unit="s", tz="UTC"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
resample_origin = resample_start
|
resample_origin = pd.Timestamp(resample_start)
|
||||||
else:
|
else:
|
||||||
# Preserve original behaviour: buckets start at the resample start.
|
# Preserve original behaviour: buckets start at the resample start.
|
||||||
resample_origin = resample_start
|
resample_origin = (
|
||||||
if resample_origin is None:
|
pd.Timestamp(resample_start) if resample_start is not None else "start_day"
|
||||||
# We have no resample origin - take start of day as default
|
)
|
||||||
resample_origin = "start_day"
|
|
||||||
|
|
||||||
# Check for numeric values
|
# Check for numeric values
|
||||||
numeric_series = pd.to_numeric(series, errors="coerce") # ensures float64, not object dtype
|
numeric_series = pd.to_numeric(series, errors="coerce") # ensures float64, not object dtype
|
||||||
@@ -1746,7 +1762,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
|||||||
# ==================== DataProvider ====================
|
# ==================== DataProvider ====================
|
||||||
|
|
||||||
|
|
||||||
class DataProvider(SingletonMixin, DataSequence):
|
class DataProvider(SingletonMixin, DataSequence[DataRecordT], Generic[DataRecordT]):
|
||||||
"""Abstract base class for data providers with singleton thread-safety and configurable data parameters.
|
"""Abstract base class for data providers with singleton thread-safety and configurable data parameters.
|
||||||
|
|
||||||
This class serves as a base for managing generic data, providing an interface for derived
|
This class serves as a base for managing generic data, providing an interface for derived
|
||||||
@@ -2013,6 +2029,8 @@ class DataImportMixin(StartMixin):
|
|||||||
# Generate value_datetime_mapping once if not using datetime index
|
# Generate value_datetime_mapping once if not using datetime index
|
||||||
if not has_datetime_index:
|
if not has_datetime_index:
|
||||||
# Create values datetime list
|
# Create values datetime list
|
||||||
|
if start_datetime is None:
|
||||||
|
raise ValueError("Timezone-aware datetime required")
|
||||||
start_timestamp = DatabaseTimestamp.from_datetime(start_datetime)
|
start_timestamp = DatabaseTimestamp.from_datetime(start_datetime)
|
||||||
value_db_datetimes = list(
|
value_db_datetimes = list(
|
||||||
self.db_generate_timestamps(start_timestamp, values_count, interval) # type: ignore[attr-defined]
|
self.db_generate_timestamps(start_timestamp, values_count, interval) # type: ignore[attr-defined]
|
||||||
@@ -2094,6 +2112,7 @@ class DataImportMixin(StartMixin):
|
|||||||
json_str = json_str.strip() # strip remaining white space at start and end
|
json_str = json_str.strip() # strip remaining white space at start and end
|
||||||
|
|
||||||
# Try pandas dataframe with orient="split"
|
# Try pandas dataframe with orient="split"
|
||||||
|
import_data: PydanticDateTimeDataFrame | PydanticDateTimeData | dict[str, Any]
|
||||||
try:
|
try:
|
||||||
import_data = PydanticDateTimeDataFrame.model_validate_json(json_str)
|
import_data = PydanticDateTimeDataFrame.model_validate_json(json_str)
|
||||||
await self._import_from_dataframe(import_data.to_dataframe())
|
await self._import_from_dataframe(import_data.to_dataframe())
|
||||||
@@ -2123,7 +2142,7 @@ class DataImportMixin(StartMixin):
|
|||||||
|
|
||||||
# Use simple dict format
|
# Use simple dict format
|
||||||
try:
|
try:
|
||||||
import_data = json.loads(json_str)
|
import_data = cast(dict[str, Any], json.loads(json_str))
|
||||||
await self._import_from_dict(
|
await self._import_from_dict(
|
||||||
import_data, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval
|
import_data, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval
|
||||||
)
|
)
|
||||||
@@ -2332,7 +2351,7 @@ class DataImportMixin(StartMixin):
|
|||||||
# ==================== DataImportProvider ====================
|
# ==================== DataImportProvider ====================
|
||||||
|
|
||||||
|
|
||||||
class DataImportProvider(DataImportMixin, DataProvider):
|
class DataImportProvider(DataImportMixin, DataProvider[DataRecordT], Generic[DataRecordT]):
|
||||||
"""Abstract base class for data providers that import generic data.
|
"""Abstract base class for data providers that import generic data.
|
||||||
|
|
||||||
This class is designed to handle generic data provided in the form of a key-value dictionary.
|
This class is designed to handle generic data provided in the form of a key-value dictionary.
|
||||||
@@ -2350,7 +2369,10 @@ class DataImportProvider(DataImportMixin, DataProvider):
|
|||||||
# ==================== DataContainer ====================
|
# ==================== DataContainer ====================
|
||||||
|
|
||||||
|
|
||||||
class DataContainer(SingletonMixin, DataABC):
|
DataProviderT = TypeVar("DataProviderT", bound=DataProvider)
|
||||||
|
|
||||||
|
|
||||||
|
class DataContainer(SingletonMixin, DataABC, Generic[DataProviderT]):
|
||||||
"""A container for managing multiple DataProvider instances.
|
"""A container for managing multiple DataProvider instances.
|
||||||
|
|
||||||
This class enables access to data from multiple data providers, supporting retrieval and
|
This class enables access to data from multiple data providers, supporting retrieval and
|
||||||
@@ -2363,7 +2385,7 @@ class DataContainer(SingletonMixin, DataABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# To be overloaded by derived classes.
|
# To be overloaded by derived classes.
|
||||||
providers: list[DataProvider] = Field(
|
providers: list[DataProviderT] = Field(
|
||||||
default_factory=list, json_schema_extra={"description": "List of data providers"}
|
default_factory=list, json_schema_extra={"description": "List of data providers"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2381,7 +2403,7 @@ class DataContainer(SingletonMixin, DataABC):
|
|||||||
return lock
|
return lock
|
||||||
|
|
||||||
@field_validator("providers", mode="after")
|
@field_validator("providers", mode="after")
|
||||||
def check_providers(cls, value: list[DataProvider]) -> list[DataProvider]:
|
def check_providers(cls, value: list[DataProviderT]) -> list[DataProviderT]:
|
||||||
# Check each item in the list
|
# Check each item in the list
|
||||||
for item in value:
|
for item in value:
|
||||||
if not isinstance(item, DataProvider):
|
if not isinstance(item, DataProvider):
|
||||||
@@ -2422,7 +2444,8 @@ class DataContainer(SingletonMixin, DataABC):
|
|||||||
return
|
return
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[str]:
|
# EOS collections iterate over their elements instead of BaseModel field/value pairs.
|
||||||
|
def __iter__(self) -> Iterator[str]: # type: ignore[override]
|
||||||
"""Return an iterator over all unique keys available across providers.
|
"""Return an iterator over all unique keys available across providers.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -2894,7 +2917,7 @@ class DataContainer(SingletonMixin, DataABC):
|
|||||||
if key_error:
|
if key_error:
|
||||||
raise KeyError(f"key `{key}` is not in predictions")
|
raise KeyError(f"key `{key}` is not in predictions")
|
||||||
|
|
||||||
def provider_by_id(self, provider_id: str) -> DataProvider:
|
def provider_by_id(self, provider_id: str) -> DataProviderT:
|
||||||
"""Retrieves a data provider by its unique identifier.
|
"""Retrieves a data provider by its unique identifier.
|
||||||
|
|
||||||
This method searches through the list of all available providers and
|
This method searches through the list of all available providers and
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from typing import (
|
|||||||
Type,
|
Type,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -39,6 +40,7 @@ from akkudoktoreos.core.types import (
|
|||||||
ResampleMethod,
|
ResampleMethod,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.utils.datetimeutil import (
|
from akkudoktoreos.utils.datetimeutil import (
|
||||||
|
UTC,
|
||||||
DateTime,
|
DateTime,
|
||||||
Duration,
|
Duration,
|
||||||
to_datetime,
|
to_datetime,
|
||||||
@@ -278,9 +280,10 @@ class DatabaseBackendABC(ABC, ConfigMixin, SingletonMixin):
|
|||||||
|
|
||||||
|
|
||||||
class DataRecordProtocol(Protocol):
|
class DataRecordProtocol(Protocol):
|
||||||
date_time: DateTime
|
# Records may be incomplete in memory; database entry points require a timestamp.
|
||||||
|
date_time: Optional[DateTime]
|
||||||
|
|
||||||
def __init__(self, date_time: Any) -> None: ...
|
def __init__(self, date_time: Optional[DateTime]) -> None: ...
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> Any: ...
|
def __getitem__(self, key: str) -> Any: ...
|
||||||
|
|
||||||
@@ -303,15 +306,13 @@ class DatabaseTimestamp(str):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_datetime(cls, dt: DateTime) -> "DatabaseTimestamp":
|
def from_datetime(cls, dt: DateTime) -> "DatabaseTimestamp":
|
||||||
if dt.tz is None:
|
if dt is None or dt.tz is None:
|
||||||
raise ValueError("Timezone-aware datetime required")
|
raise ValueError("Timezone-aware datetime required")
|
||||||
|
|
||||||
return cls(dt.in_timezone("UTC").format("YYYYMMDDTHHmmss[Z]"))
|
return cls(dt.in_timezone("UTC").format("YYYYMMDDTHHmmss[Z]"))
|
||||||
|
|
||||||
def to_datetime(self) -> DateTime:
|
def to_datetime(self) -> DateTime:
|
||||||
from pendulum import parse
|
return to_datetime(self, in_timezone="UTC")
|
||||||
|
|
||||||
return parse(self)
|
|
||||||
|
|
||||||
|
|
||||||
class _DatabaseTimestampUnbound(str):
|
class _DatabaseTimestampUnbound(str):
|
||||||
@@ -801,6 +802,19 @@ class DatabaseRecordProtocolMixin(
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _db_require_date_time(self, record: T_Record) -> DateTime:
|
||||||
|
"""Validate a record's timestamp before it enters the database index."""
|
||||||
|
date_time = record.date_time
|
||||||
|
if date_time is None:
|
||||||
|
try:
|
||||||
|
namespace = self.db_namespace()
|
||||||
|
except NotImplementedError:
|
||||||
|
namespace = self.__class__.__name__
|
||||||
|
raise ValueError(
|
||||||
|
f"Database records require a datetime (namespace='{namespace}', got {record!r})"
|
||||||
|
)
|
||||||
|
return date_time
|
||||||
|
|
||||||
def _db_serialize_record(self, record: T_Record) -> bytes:
|
def _db_serialize_record(self, record: T_Record) -> bytes:
|
||||||
"""Serialize a DataRecord to bytes."""
|
"""Serialize a DataRecord to bytes."""
|
||||||
if self.database is None:
|
if self.database is None:
|
||||||
@@ -1404,10 +1418,14 @@ class DatabaseRecordProtocolMixin(
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Indexed records have timestamps, validated when inserted or loaded.
|
||||||
|
# We validate again to be safe for future refactoring/ changes.
|
||||||
record = min(
|
record = min(
|
||||||
candidates,
|
candidates,
|
||||||
key=lambda r: abs(
|
key=lambda r: abs(
|
||||||
(r.date_time - DatabaseTimestamp.to_datetime(target_timestamp)).total_seconds()
|
(
|
||||||
|
self._db_require_date_time(r) - DatabaseTimestamp.to_datetime(target_timestamp)
|
||||||
|
).total_seconds()
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1417,7 +1435,8 @@ class DatabaseRecordProtocolMixin(
|
|||||||
if (
|
if (
|
||||||
abs(
|
abs(
|
||||||
(
|
(
|
||||||
record.date_time - DatabaseTimestamp.to_datetime(target_timestamp)
|
self._db_require_date_time(record)
|
||||||
|
- DatabaseTimestamp.to_datetime(target_timestamp)
|
||||||
).total_seconds()
|
).total_seconds()
|
||||||
)
|
)
|
||||||
> half_seconds
|
> half_seconds
|
||||||
@@ -1436,7 +1455,7 @@ class DatabaseRecordProtocolMixin(
|
|||||||
await self._db_ensure_initialized()
|
await self._db_ensure_initialized()
|
||||||
|
|
||||||
# Ensure normalized to UTC
|
# Ensure normalized to UTC
|
||||||
db_record_date_time = DatabaseTimestamp.from_datetime(record.date_time)
|
db_record_date_time = DatabaseTimestamp.from_datetime(self._db_require_date_time(record))
|
||||||
|
|
||||||
await self._db_ensure_loaded(
|
await self._db_ensure_loaded(
|
||||||
start_timestamp=db_record_date_time,
|
start_timestamp=db_record_date_time,
|
||||||
@@ -1533,7 +1552,9 @@ class DatabaseRecordProtocolMixin(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
record = self._db_deserialize_record(value)
|
record = self._db_deserialize_record(value)
|
||||||
db_record_date_time = DatabaseTimestamp.from_datetime(record.date_time)
|
db_record_date_time = DatabaseTimestamp.from_datetime(
|
||||||
|
self._db_require_date_time(record)
|
||||||
|
)
|
||||||
|
|
||||||
# Do not resurrect explicitly deleted records
|
# Do not resurrect explicitly deleted records
|
||||||
if db_record_date_time in self._db_deleted_timestamps:
|
if db_record_date_time in self._db_deleted_timestamps:
|
||||||
@@ -1645,7 +1666,11 @@ class DatabaseRecordProtocolMixin(
|
|||||||
start_idx = bisect.bisect_left(self._db_sorted_timestamps, start_timestamp)
|
start_idx = bisect.bisect_left(self._db_sorted_timestamps, start_timestamp)
|
||||||
|
|
||||||
for record in self.records[start_idx:]:
|
for record in self.records[start_idx:]:
|
||||||
record_date_time_timestamp = DatabaseTimestamp.from_datetime(record.date_time)
|
# Indexed records were validated on insertion or loading.
|
||||||
|
# We validate againg to be safe for future refactoring/ changes.
|
||||||
|
record_date_time_timestamp = DatabaseTimestamp.from_datetime(
|
||||||
|
self._db_require_date_time(record)
|
||||||
|
)
|
||||||
|
|
||||||
if start_timestamp and record_date_time_timestamp < start_timestamp:
|
if start_timestamp and record_date_time_timestamp < start_timestamp:
|
||||||
continue
|
continue
|
||||||
@@ -1666,7 +1691,9 @@ class DatabaseRecordProtocolMixin(
|
|||||||
# Ensure db in memory data and metadata is initialized
|
# Ensure db in memory data and metadata is initialized
|
||||||
await self._db_ensure_initialized()
|
await self._db_ensure_initialized()
|
||||||
|
|
||||||
record_date_time_timestamp = DatabaseTimestamp.from_datetime(record.date_time)
|
record_date_time_timestamp = DatabaseTimestamp.from_datetime(
|
||||||
|
self._db_require_date_time(record)
|
||||||
|
)
|
||||||
self._db_dirty_timestamps.add(record_date_time_timestamp)
|
self._db_dirty_timestamps.add(record_date_time_timestamp)
|
||||||
|
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
@@ -1691,10 +1718,20 @@ class DatabaseRecordProtocolMixin(
|
|||||||
save_items = []
|
save_items = []
|
||||||
for dt in self._db_dirty_timestamps:
|
for dt in self._db_dirty_timestamps:
|
||||||
record = self._db_record_index.get(dt)
|
record = self._db_record_index.get(dt)
|
||||||
if record:
|
if record is None:
|
||||||
key = self._db_key_from_timestamp(dt)
|
continue
|
||||||
value = self._db_serialize_record(record)
|
# Do sanity checks on the record - date_time set and no shift since insertion
|
||||||
save_items.append((key, value))
|
current_ts = DatabaseTimestamp.from_datetime(self._db_require_date_time(record))
|
||||||
|
if current_ts != dt:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Record date_time was mutated after insertion "
|
||||||
|
f"(index key {dt!r} != current {current_ts!r}); "
|
||||||
|
"use delete_by_datetime()+insert_by_datetime() to re-time a record."
|
||||||
|
)
|
||||||
|
# Add to save
|
||||||
|
key = self._db_key_from_timestamp(dt)
|
||||||
|
value = self._db_serialize_record(record)
|
||||||
|
save_items.append((key, value))
|
||||||
saved_count = len(save_items)
|
saved_count = len(save_items)
|
||||||
if saved_count:
|
if saved_count:
|
||||||
await self.database.save_records(save_items, namespace=namespace)
|
await self.database.save_records(save_items, namespace=namespace)
|
||||||
@@ -1969,7 +2006,7 @@ class DatabaseRecordProtocolMixin(
|
|||||||
# run — they are inside the age window but straddle an incomplete bucket.
|
# run — they are inside the age window but straddle an incomplete bucket.
|
||||||
raw_cutoff_epoch = int(raw_cutoff_dt.timestamp())
|
raw_cutoff_epoch = int(raw_cutoff_dt.timestamp())
|
||||||
floored_cutoff_epoch = (raw_cutoff_epoch // interval_sec) * interval_sec
|
floored_cutoff_epoch = (raw_cutoff_epoch // interval_sec) * interval_sec
|
||||||
new_cutoff_dt = DateTime.fromtimestamp(floored_cutoff_epoch, tz="UTC")
|
new_cutoff_dt = DateTime.fromtimestamp(floored_cutoff_epoch, tz=UTC)
|
||||||
new_cutoff_ts = DatabaseTimestamp.from_datetime(new_cutoff_dt)
|
new_cutoff_ts = DatabaseTimestamp.from_datetime(new_cutoff_dt)
|
||||||
|
|
||||||
# ---- Determine window start (incremental) ------------------------
|
# ---- Determine window start (incremental) ------------------------
|
||||||
@@ -2001,7 +2038,7 @@ class DatabaseRecordProtocolMixin(
|
|||||||
# overwritten with the same values).
|
# overwritten with the same values).
|
||||||
raw_start_epoch = int(raw_window_start_dt.timestamp())
|
raw_start_epoch = int(raw_window_start_dt.timestamp())
|
||||||
floored_start_epoch = (raw_start_epoch // interval_sec) * interval_sec
|
floored_start_epoch = (raw_start_epoch // interval_sec) * interval_sec
|
||||||
window_start_dt = DateTime.fromtimestamp(floored_start_epoch, tz="UTC")
|
window_start_dt = DateTime.fromtimestamp(floored_start_epoch, tz=UTC)
|
||||||
window_start_ts = DatabaseTimestamp.from_datetime(window_start_dt)
|
window_start_ts = DatabaseTimestamp.from_datetime(window_start_dt)
|
||||||
|
|
||||||
window_end_dt = new_cutoff_dt # exclusive upper bound, already aligned
|
window_end_dt = new_cutoff_dt # exclusive upper bound, already aligned
|
||||||
@@ -2031,13 +2068,19 @@ class DatabaseRecordProtocolMixin(
|
|||||||
# Data is already sparse — check whether timestamps are aligned.
|
# Data is already sparse — check whether timestamps are aligned.
|
||||||
# If every record already sits on an interval boundary, nothing to do.
|
# If every record already sits on an interval boundary, nothing to do.
|
||||||
# If any are misaligned, snap them in place without resampling.
|
# If any are misaligned, snap them in place without resampling.
|
||||||
|
|
||||||
|
# Indexed records have timestamps, validated when inserted or loaded.
|
||||||
|
# We validate again to be safe for future refactoring/ changes.
|
||||||
records_in_window = [
|
records_in_window = [
|
||||||
r
|
r
|
||||||
for r in self.records
|
for r in self.records
|
||||||
if r.date_time is not None and window_start_dt <= r.date_time < window_end_dt
|
if window_start_dt <= self._db_require_date_time(r) < window_end_dt
|
||||||
]
|
]
|
||||||
|
# The window filter above raises an exception for records without timestamps.
|
||||||
misaligned = [
|
misaligned = [
|
||||||
r for r in records_in_window if int(r.date_time.timestamp()) % interval_sec != 0
|
r
|
||||||
|
for r in records_in_window
|
||||||
|
if int(cast(DateTime, r.date_time).timestamp()) % interval_sec != 0
|
||||||
]
|
]
|
||||||
if not misaligned:
|
if not misaligned:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -2065,8 +2108,8 @@ class DatabaseRecordProtocolMixin(
|
|||||||
# Process chronologically so the earliest record's values win when
|
# Process chronologically so the earliest record's values win when
|
||||||
# multiple records floor to the same bucket.
|
# multiple records floor to the same bucket.
|
||||||
snapped_bucket: dict[int, dict[str, Any]] = {}
|
snapped_bucket: dict[int, dict[str, Any]] = {}
|
||||||
for r in sorted(records_in_window, key=lambda x: x.date_time):
|
for r in sorted(records_in_window, key=lambda r: cast(DateTime, r.date_time)):
|
||||||
ts_epoch = int(r.date_time.timestamp())
|
ts_epoch = int(cast(DateTime, r.date_time).timestamp())
|
||||||
snapped_epoch = (ts_epoch // interval_sec) * interval_sec
|
snapped_epoch = (ts_epoch // interval_sec) * interval_sec
|
||||||
bucket = snapped_bucket.setdefault(snapped_epoch, {})
|
bucket = snapped_bucket.setdefault(snapped_epoch, {})
|
||||||
for key in self.record_keys_writable:
|
for key in self.record_keys_writable:
|
||||||
@@ -2089,7 +2132,7 @@ class DatabaseRecordProtocolMixin(
|
|||||||
for snapped_epoch, values in snapped_bucket.items():
|
for snapped_epoch, values in snapped_bucket.items():
|
||||||
if not values:
|
if not values:
|
||||||
continue
|
continue
|
||||||
snapped_dt = DateTime.fromtimestamp(snapped_epoch, tz="UTC")
|
snapped_dt = DateTime.fromtimestamp(snapped_epoch, tz=UTC)
|
||||||
record = self.record_class()(date_time=snapped_dt, **values)
|
record = self.record_class()(date_time=snapped_dt, **values)
|
||||||
await self.db_insert_record(record, mark_dirty=True)
|
await self.db_insert_record(record, mark_dirty=True)
|
||||||
|
|
||||||
@@ -2148,7 +2191,7 @@ class DatabaseRecordProtocolMixin(
|
|||||||
while first_bucket_epoch < int(window_start_dt.timestamp()):
|
while first_bucket_epoch < int(window_start_dt.timestamp()):
|
||||||
first_bucket_epoch += interval_sec
|
first_bucket_epoch += interval_sec
|
||||||
compacted_timestamps = [
|
compacted_timestamps = [
|
||||||
DateTime.fromtimestamp(first_bucket_epoch + i * interval_sec, tz="UTC")
|
DateTime.fromtimestamp(first_bucket_epoch + i * interval_sec, tz=UTC)
|
||||||
for i in range(len(array))
|
for i in range(len(array))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class EnergyManagement(
|
|||||||
def start_datetime(self) -> DateTime:
|
def start_datetime(self) -> DateTime:
|
||||||
"""The starting datetime of the current or latest energy management."""
|
"""The starting datetime of the current or latest energy management."""
|
||||||
if EnergyManagement._start_datetime is None:
|
if EnergyManagement._start_datetime is None:
|
||||||
EnergyManagement.set_start_datetime()
|
return EnergyManagement.set_start_datetime()
|
||||||
return EnergyManagement._start_datetime
|
return EnergyManagement._start_datetime
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional, cast
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -176,8 +176,8 @@ def read_file_log(
|
|||||||
raise FileNotFoundError("Log file not found")
|
raise FileNotFoundError("Log file not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from_dt = pendulum.parse(from_time) if from_time else None
|
from_dt = cast(pendulum.DateTime, pendulum.parse(from_time)) if from_time else None
|
||||||
to_dt = pendulum.parse(to_time) if to_time else None
|
to_dt = cast(pendulum.DateTime, pendulum.parse(to_time)) if to_time else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Invalid date/time format: {e}")
|
raise ValueError(f"Invalid date/time format: {e}")
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ def read_file_log(
|
|||||||
return False
|
return False
|
||||||
if from_dt or to_dt:
|
if from_dt or to_dt:
|
||||||
try:
|
try:
|
||||||
log_time = pendulum.parse(log["time"])
|
log_time = cast(pendulum.DateTime, pendulum.parse(log["time"]))
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
if from_dt and log_time < from_dt:
|
if from_dt and log_time < from_dt:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class LoggingCommonSettings(SettingsBaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": "Logging level for API response.",
|
"description": "Logging level for API response.",
|
||||||
"examples": LOGGING_LEVELS,
|
"examples": [*LOGGING_LEVELS],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ class LoggingCommonSettings(SettingsBaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": "Logging level for logging to console.",
|
"description": "Logging level for logging to console.",
|
||||||
"examples": LOGGING_LEVELS,
|
"examples": [*LOGGING_LEVELS],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class LoggingCommonSettings(SettingsBaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": "Logging level for logging to file.",
|
"description": "Logging level for logging to file.",
|
||||||
"examples": LOGGING_LEVELS,
|
"examples": [*LOGGING_LEVELS],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,17 @@ import uuid
|
|||||||
import weakref
|
import weakref
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import (
|
from typing import (
|
||||||
|
Annotated,
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
Dict,
|
Dict,
|
||||||
List,
|
List,
|
||||||
Optional,
|
Optional,
|
||||||
|
Self,
|
||||||
Type,
|
Type,
|
||||||
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
|
cast,
|
||||||
get_args,
|
get_args,
|
||||||
get_origin,
|
get_origin,
|
||||||
)
|
)
|
||||||
@@ -39,6 +43,7 @@ from pydantic import (
|
|||||||
BaseModel,
|
BaseModel,
|
||||||
ConfigDict,
|
ConfigDict,
|
||||||
Field,
|
Field,
|
||||||
|
GetPydanticSchema,
|
||||||
PrivateAttr,
|
PrivateAttr,
|
||||||
RootModel,
|
RootModel,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -49,6 +54,7 @@ from pydantic.fields import ComputedFieldInfo, FieldInfo
|
|||||||
|
|
||||||
from akkudoktoreos.utils.datetimeutil import (
|
from akkudoktoreos.utils.datetimeutil import (
|
||||||
DateTime,
|
DateTime,
|
||||||
|
Duration,
|
||||||
to_datetime,
|
to_datetime,
|
||||||
to_duration,
|
to_duration,
|
||||||
to_timezone,
|
to_timezone,
|
||||||
@@ -415,7 +421,7 @@ class PydanticModelNestedValueMixin:
|
|||||||
# If this is the final key, set the value
|
# If this is the final key, set the value
|
||||||
if is_final_key:
|
if is_final_key:
|
||||||
try:
|
try:
|
||||||
model.validate_and_set(key, value)
|
getattr(model, "validate_and_set")(key, value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Error updating model: {e}") from e
|
raise ValueError(f"Error updating model: {e}") from e
|
||||||
return
|
return
|
||||||
@@ -549,10 +555,10 @@ class PydanticModelNestedValueMixin:
|
|||||||
if not inspect.isclass(model):
|
if not inspect.isclass(model):
|
||||||
raise TypeError(f"Model '{model}' is not of class type.")
|
raise TypeError(f"Model '{model}' is not of class type.")
|
||||||
|
|
||||||
if key not in model.model_fields: # type: ignore[attr-defined]
|
if key not in model.model_fields:
|
||||||
raise TypeError(f"Field '{key}' does not exist in model '{model.__name__}'.")
|
raise TypeError(f"Field '{key}' does not exist in model '{model.__name__}'.")
|
||||||
|
|
||||||
field_annotation = model.model_fields[key].annotation # type: ignore[attr-defined]
|
field_annotation = model.model_fields[key].annotation
|
||||||
if not field_annotation:
|
if not field_annotation:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Missing type annotation for field '{key}' in model '{model.__name__}'."
|
f"Missing type annotation for field '{key}' in model '{model.__name__}'."
|
||||||
@@ -563,6 +569,8 @@ class PydanticModelNestedValueMixin:
|
|||||||
|
|
||||||
while queue:
|
while queue:
|
||||||
annotation = queue.pop(0)
|
annotation = queue.pop(0)
|
||||||
|
if isinstance(annotation, TypeVar):
|
||||||
|
annotation = annotation.__bound__ or Any
|
||||||
origin = get_origin(annotation)
|
origin = get_origin(annotation)
|
||||||
args = get_args(annotation)
|
args = get_args(annotation)
|
||||||
|
|
||||||
@@ -679,7 +687,7 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
|||||||
"""Resets the fields to their default values."""
|
"""Resets the fields to their default values."""
|
||||||
for field_name, field_info in self.__class__.model_fields.items():
|
for field_name, field_info in self.__class__.model_fields.items():
|
||||||
if field_info.default_factory is not None: # Handle fields with default_factory
|
if field_info.default_factory is not None: # Handle fields with default_factory
|
||||||
default_value = field_info.default_factory()
|
default_value = field_info.get_default(call_default_factory=True)
|
||||||
else:
|
else:
|
||||||
default_value = field_info.default
|
default_value = field_info.default
|
||||||
try:
|
try:
|
||||||
@@ -707,7 +715,7 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
|||||||
return self.model_dump()
|
return self.model_dump()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls: Type["PydanticBaseModel"], data: dict) -> "PydanticBaseModel":
|
def from_dict(cls, data: dict) -> Self:
|
||||||
"""Create a PydanticBaseModel instance from a dictionary.
|
"""Create a PydanticBaseModel instance from a dictionary.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -735,7 +743,7 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
|||||||
return self.model_dump_json()
|
return self.model_dump_json()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls: Type["PydanticBaseModel"], json_str: str) -> "PydanticBaseModel":
|
def from_json(cls, json_str: str) -> Self:
|
||||||
"""Create an instance of the PydanticBaseModel class or its subclass from a JSON string.
|
"""Create an instance of the PydanticBaseModel class or its subclass from a JSON string.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -926,6 +934,10 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DateTimeDataInput = dict[str, str | list[float | int | str | None]]
|
||||||
|
DateTimeDataValues = dict[str, str | DateTime | Duration | list[float | int | str | None]]
|
||||||
|
|
||||||
|
|
||||||
class PydanticDateTimeData(RootModel):
|
class PydanticDateTimeData(RootModel):
|
||||||
"""Pydantic model for time series data with consistent value lengths.
|
"""Pydantic model for time series data with consistent value lengths.
|
||||||
|
|
||||||
@@ -948,13 +960,16 @@ class PydanticDateTimeData(RootModel):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
root: Dict[str, Union[str, List[Union[float, int, str, None]]]]
|
# The wire format contains strings; validate_root normalizes the two
|
||||||
|
# indexing values to Pendulum objects. Keep the existing input schema.
|
||||||
|
root: Annotated[
|
||||||
|
DateTimeDataValues,
|
||||||
|
GetPydanticSchema(lambda source_type, handler: handler(DateTimeDataInput)),
|
||||||
|
]
|
||||||
|
|
||||||
@field_validator("root", mode="after")
|
@field_validator("root", mode="after")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_root(
|
def validate_root(cls, value: dict[str, Any]) -> DateTimeDataValues:
|
||||||
cls, value: Dict[str, Union[str, List[Union[float, int, str, None]]]]
|
|
||||||
) -> Dict[str, Union[str, List[Union[float, int, str, None]]]]:
|
|
||||||
# Validate that all keys are strings
|
# Validate that all keys are strings
|
||||||
if not all(isinstance(k, str) for k in value.keys()):
|
if not all(isinstance(k, str) for k in value.keys()):
|
||||||
raise ValueError("All keys in the dictionary must be strings.")
|
raise ValueError("All keys in the dictionary must be strings.")
|
||||||
@@ -977,7 +992,7 @@ class PydanticDateTimeData(RootModel):
|
|||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Union[str, List[Union[float, int, str, None]]]]:
|
def to_dict(self) -> DateTimeDataValues:
|
||||||
"""Convert the model to a plain dictionary.
|
"""Convert the model to a plain dictionary.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1176,8 +1191,8 @@ class PydanticDateTimeDataFrame(PydanticBaseModel):
|
|||||||
df[col] = df[col].dt.tz_convert(resolved_tz)
|
df[col] = df[col].dt.tz_convert(resolved_tz)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
data=df.to_dict(orient="index"),
|
data=cast(dict[str, dict[str, Any]], df.to_dict(orient="index")),
|
||||||
dtypes={col: str(dtype) for col, dtype in df.dtypes.items()},
|
dtypes=cast(dict[str, str], {col: str(dtype) for col, dtype in df.dtypes.items()}),
|
||||||
tz=resolved_tz,
|
tz=resolved_tz,
|
||||||
datetime_columns=datetime_columns,
|
datetime_columns=datetime_columns,
|
||||||
)
|
)
|
||||||
@@ -1401,10 +1416,10 @@ class PydanticDateTimeSeries(PydanticBaseModel):
|
|||||||
series.index = index
|
series.index = index
|
||||||
|
|
||||||
if len(index) > 0:
|
if len(index) > 0:
|
||||||
tz = to_datetime(series.index[0]).timezone.name
|
tz = to_datetime(series.index[0]).timezone_name
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
data=series.to_dict(),
|
data=cast(dict[str, Any], series.to_dict()),
|
||||||
dtype=str(series.dtype),
|
dtype=str(series.dtype),
|
||||||
tz=tz,
|
tz=tz,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
|||||||
def validate_and_sort_charge_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
|
def validate_and_sort_charge_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
|
||||||
# None means fallback to default values
|
# None means fallback to default values
|
||||||
if v is None:
|
if v is None:
|
||||||
return BATTERY_DEFAULT_CHARGE_RATES.copy()
|
return np.asarray(BATTERY_DEFAULT_CHARGE_RATES, dtype=float)
|
||||||
|
|
||||||
# Convert to numpy array
|
# Convert to numpy array
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
@@ -345,10 +345,10 @@ class DevicesCommonSettings(SettingsBaseModel):
|
|||||||
|
|
||||||
if self.max_batteries and self.batteries:
|
if self.max_batteries and self.batteries:
|
||||||
for battery in self.batteries:
|
for battery in self.batteries:
|
||||||
keys.extend(battery.measurement_keys)
|
keys.extend(battery.measurement_keys or [])
|
||||||
if self.max_electric_vehicles and self.electric_vehicles:
|
if self.max_electric_vehicles and self.electric_vehicles:
|
||||||
for electric_vehicle in self.electric_vehicles:
|
for electric_vehicle in self.electric_vehicles:
|
||||||
keys.extend(electric_vehicle.measurement_keys)
|
keys.extend(electric_vehicle.measurement_keys or [])
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class MeasurementDataRecord(DataRecord):
|
|||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
|
||||||
class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
class Measurement(SingletonMixin, DataImportMixin, DataSequence[MeasurementDataRecord]):
|
||||||
"""Singleton class that holds measurement data records.
|
"""Singleton class that holds measurement data records.
|
||||||
|
|
||||||
Measurements can be provided programmatically or read from JSON string or file.
|
Measurements can be provided programmatically or read from JSON string or file.
|
||||||
@@ -168,6 +168,8 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
|||||||
np.ndarray: A NumPy Array of the energy [kWh] per interval values calculated from
|
np.ndarray: A NumPy Array of the energy [kWh] per interval values calculated from
|
||||||
the meter readings.
|
the meter readings.
|
||||||
"""
|
"""
|
||||||
|
if start_datetime is None or end_datetime is None:
|
||||||
|
raise ValueError("Start and end datetimes are required for energy calculation")
|
||||||
size = self._interval_count(start_datetime, end_datetime, interval)
|
size = self._interval_count(start_datetime, end_datetime, interval)
|
||||||
|
|
||||||
energy_mr_array = await self.key_to_array(
|
energy_mr_array = await self.key_to_array(
|
||||||
@@ -237,6 +239,8 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
|||||||
end_datetime = await self.max_datetime()
|
end_datetime = await self.max_datetime()
|
||||||
if end_datetime:
|
if end_datetime:
|
||||||
end_datetime = end_datetime.add(seconds=1)
|
end_datetime = end_datetime.add(seconds=1)
|
||||||
|
if start_datetime is None or end_datetime is None:
|
||||||
|
raise ValueError("Start and end datetimes are required for energy calculation")
|
||||||
size = self._interval_count(start_datetime, end_datetime, interval)
|
size = self._interval_count(start_datetime, end_datetime, interval)
|
||||||
load_total_kwh_array = np.zeros(size)
|
load_total_kwh_array = np.zeros(size)
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class GeneticSimulation(PydanticBaseModel):
|
|||||||
self.pv_prediction_wh = np.array(parameters.pv_forecast_wh, float)
|
self.pv_prediction_wh = np.array(parameters.pv_forecast_wh, float)
|
||||||
self.elect_price_hourly = np.array(parameters.electricity_price_per_wh, float)
|
self.elect_price_hourly = np.array(parameters.electricity_price_per_wh, float)
|
||||||
self.elect_revenue_per_hour_arr = (
|
self.elect_revenue_per_hour_arr = (
|
||||||
parameters.feed_in_tariff_per_wh
|
np.asarray(parameters.feed_in_tariff_per_wh, dtype=float)
|
||||||
if isinstance(parameters.feed_in_tariff_per_wh, list)
|
if isinstance(parameters.feed_in_tariff_per_wh, list)
|
||||||
else np.full(len(self.load_energy_array), parameters.feed_in_tariff_per_wh, float)
|
else np.full(len(self.load_energy_array), parameters.feed_in_tariff_per_wh, float)
|
||||||
)
|
)
|
||||||
@@ -1205,21 +1205,21 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Simulation may have changed something, use simulation values
|
# Simulation may have changed something, use simulation values
|
||||||
ac_charge_hours = self.simulation.ac_charge_hours
|
ac_charge_hours = (
|
||||||
if ac_charge_hours is None:
|
self.simulation.ac_charge_hours.tolist()
|
||||||
ac_charge_hours = []
|
if self.simulation.ac_charge_hours is not None
|
||||||
else:
|
else []
|
||||||
ac_charge_hours = ac_charge_hours.tolist()
|
)
|
||||||
dc_charge_hours = self.simulation.dc_charge_hours
|
dc_charge_hours = (
|
||||||
if dc_charge_hours is None:
|
self.simulation.dc_charge_hours.tolist()
|
||||||
dc_charge_hours = []
|
if self.simulation.dc_charge_hours is not None
|
||||||
else:
|
else []
|
||||||
dc_charge_hours = dc_charge_hours.tolist()
|
)
|
||||||
discharge = self.simulation.bat_discharge_hours
|
discharge = (
|
||||||
if discharge is None:
|
self.simulation.bat_discharge_hours.tolist()
|
||||||
discharge = []
|
if self.simulation.bat_discharge_hours is not None
|
||||||
else:
|
else []
|
||||||
discharge = discharge.tolist()
|
)
|
||||||
|
|
||||||
return GeneticSolution(
|
return GeneticSolution(
|
||||||
**{
|
**{
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ class GeneticCommonSettings(SettingsBaseModel):
|
|||||||
# --- Penalties (existing) -------------------------------------------------
|
# --- Penalties (existing) -------------------------------------------------
|
||||||
|
|
||||||
penalties: dict[str, Union[float, int, str]] = Field(
|
penalties: dict[str, Union[float, int, str]] = Field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: dict[str, float | int | str](
|
||||||
"ev_soc_miss": 10,
|
ev_soc_miss=10,
|
||||||
"ac_charge_break_even": 1.0,
|
ac_charge_break_even=1.0,
|
||||||
},
|
),
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": "Penalty parameters used in fitness evaluation.",
|
"description": "Penalty parameters used in fitness evaluation.",
|
||||||
"examples": [{"ev_soc_miss": 10}],
|
"examples": [{"ev_soc_miss": 10}],
|
||||||
|
|||||||
@@ -141,7 +141,11 @@ class GeneticVisualizationReport(ConfigMixin):
|
|||||||
marker = markers[idx] if markers and idx < len(markers) else "o" # Marker style
|
marker = markers[idx] if markers and idx < len(markers) else "o" # Marker style
|
||||||
line_style = line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
line_style = line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
||||||
plt.plot(
|
plt.plot(
|
||||||
timestamps, y_data, label=label, marker=marker, linestyle=line_style
|
mdates.date2num(timestamps),
|
||||||
|
np.asarray(y_data, dtype=float),
|
||||||
|
label=label,
|
||||||
|
marker=marker,
|
||||||
|
linestyle=line_style,
|
||||||
) # Plot line
|
) # Plot line
|
||||||
|
|
||||||
# Format the time axis
|
# Format the time axis
|
||||||
@@ -178,8 +182,15 @@ class GeneticVisualizationReport(ConfigMixin):
|
|||||||
# Add vertical line for the current date if within the axis range
|
# Add vertical line for the current date if within the axis range
|
||||||
current_time = pendulum.now(self.config.general.timezone)
|
current_time = pendulum.now(self.config.general.timezone)
|
||||||
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
|
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
|
||||||
plt.axvline(current_time, color="r", linestyle="--", label="Now")
|
plt.axvline(mdates.date2num(current_time), color="r", linestyle="--", label="Now")
|
||||||
plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom")
|
plt.text(
|
||||||
|
mdates.date2num(current_time),
|
||||||
|
plt.ylim()[1],
|
||||||
|
"Now",
|
||||||
|
color="r",
|
||||||
|
ha="center",
|
||||||
|
va="bottom",
|
||||||
|
)
|
||||||
|
|
||||||
# Add a second x-axis on top
|
# Add a second x-axis on top
|
||||||
ax1 = plt.gca()
|
ax1 = plt.gca()
|
||||||
@@ -191,7 +202,9 @@ class GeneticVisualizationReport(ConfigMixin):
|
|||||||
# ax2.set_xticks(timestamps[::48]) # Set ticks every 12 hours
|
# 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_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_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks
|
||||||
ax2.set_xticks(timestamps[:: len(timestamps) // 12]) # Select 10 evenly spaced ticks
|
ax2.set_xticks(
|
||||||
|
mdates.date2num(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) // 24]])
|
||||||
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 12]])
|
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 12]])
|
||||||
if x2label:
|
if x2label:
|
||||||
@@ -251,7 +264,13 @@ class GeneticVisualizationReport(ConfigMixin):
|
|||||||
line_style = (
|
line_style = (
|
||||||
line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
||||||
) # Line style
|
) # Line style
|
||||||
plt.plot(x, y_data, label=label, marker=marker, linestyle=line_style) # Plot line
|
plt.plot(
|
||||||
|
x,
|
||||||
|
np.asarray(y_data, dtype=float),
|
||||||
|
label=label,
|
||||||
|
marker=marker,
|
||||||
|
linestyle=line_style,
|
||||||
|
) # Plot line
|
||||||
|
|
||||||
plt.title(title) # Set title
|
plt.title(title) # Set title
|
||||||
plt.xlabel(xlabel) # Set x-axis label
|
plt.xlabel(xlabel) # Set x-axis label
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from akkudoktoreos.devices.genetic0.genetic0homeappliance import Genetic0HomeApp
|
|||||||
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0Inverter
|
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0Inverter
|
||||||
from akkudoktoreos.optimization.genetic0.genetic0params import (
|
from akkudoktoreos.optimization.genetic0.genetic0params import (
|
||||||
Genetic0EnergyManagementParameters,
|
Genetic0EnergyManagementParameters,
|
||||||
|
Genetic0OptimizationParameters,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.optimization.genetic0.genetic0solution import (
|
from akkudoktoreos.optimization.genetic0.genetic0solution import (
|
||||||
Genetic0SimulationResult,
|
Genetic0SimulationResult,
|
||||||
@@ -128,7 +129,7 @@ class Genetic0Simulation(PydanticBaseModel):
|
|||||||
self.pv_prediction_wh = np.array(parameters.pv_forecast_wh, float)
|
self.pv_prediction_wh = np.array(parameters.pv_forecast_wh, float)
|
||||||
self.elect_price_hourly = np.array(parameters.electricity_price_per_wh, float)
|
self.elect_price_hourly = np.array(parameters.electricity_price_per_wh, float)
|
||||||
self.elect_revenue_per_hour_arr = (
|
self.elect_revenue_per_hour_arr = (
|
||||||
parameters.feed_in_tariff_per_wh
|
np.asarray(parameters.feed_in_tariff_per_wh, dtype=float)
|
||||||
if isinstance(parameters.feed_in_tariff_per_wh, list)
|
if isinstance(parameters.feed_in_tariff_per_wh, list)
|
||||||
else np.full(len(self.load_energy_array), parameters.feed_in_tariff_per_wh, float)
|
else np.full(len(self.load_energy_array), parameters.feed_in_tariff_per_wh, float)
|
||||||
)
|
)
|
||||||
@@ -741,7 +742,7 @@ class Genetic0Optimization(OptimizationBase):
|
|||||||
def evaluate(
|
def evaluate(
|
||||||
self,
|
self,
|
||||||
individual: list[int],
|
individual: list[int],
|
||||||
parameters: Genetic0EnergyManagementParameters,
|
parameters: Genetic0OptimizationParameters,
|
||||||
start_hour: int,
|
start_hour: int,
|
||||||
worst_case: bool,
|
worst_case: bool,
|
||||||
) -> tuple[float]:
|
) -> tuple[float]:
|
||||||
@@ -1056,7 +1057,7 @@ class Genetic0Optimization(OptimizationBase):
|
|||||||
|
|
||||||
def optimize_ems(
|
def optimize_ems(
|
||||||
self,
|
self,
|
||||||
parameters: Genetic0EnergyManagementParameters,
|
parameters: Genetic0OptimizationParameters,
|
||||||
start_hour: Optional[int] = None,
|
start_hour: Optional[int] = None,
|
||||||
worst_case: bool = False,
|
worst_case: bool = False,
|
||||||
ngen: Optional[int] = None,
|
ngen: Optional[int] = None,
|
||||||
@@ -1208,21 +1209,21 @@ class Genetic0Optimization(OptimizationBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Simulation may have changed something, use simulation values
|
# Simulation may have changed something, use simulation values
|
||||||
ac_charge_hours = self.simulation.ac_charge_hours
|
ac_charge_hours = (
|
||||||
if ac_charge_hours is None:
|
self.simulation.ac_charge_hours.tolist()
|
||||||
ac_charge_hours = []
|
if self.simulation.ac_charge_hours is not None
|
||||||
else:
|
else []
|
||||||
ac_charge_hours = ac_charge_hours.tolist()
|
)
|
||||||
dc_charge_hours = self.simulation.dc_charge_hours
|
dc_charge_hours = (
|
||||||
if dc_charge_hours is None:
|
self.simulation.dc_charge_hours.tolist()
|
||||||
dc_charge_hours = []
|
if self.simulation.dc_charge_hours is not None
|
||||||
else:
|
else []
|
||||||
dc_charge_hours = dc_charge_hours.tolist()
|
)
|
||||||
discharge = self.simulation.bat_discharge_hours
|
discharge = (
|
||||||
if discharge is None:
|
self.simulation.bat_discharge_hours.tolist()
|
||||||
discharge = []
|
if self.simulation.bat_discharge_hours is not None
|
||||||
else:
|
else []
|
||||||
discharge = discharge.tolist()
|
)
|
||||||
|
|
||||||
return Genetic0Solution(
|
return Genetic0Solution(
|
||||||
**{
|
**{
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ class Genetic0CommonSettings(SettingsBaseModel):
|
|||||||
# --- Penalties (existing) -------------------------------------------------
|
# --- Penalties (existing) -------------------------------------------------
|
||||||
|
|
||||||
penalties: dict[str, Union[float, int, str]] = Field(
|
penalties: dict[str, Union[float, int, str]] = Field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: dict[str, float | int | str](
|
||||||
"ev_soc_miss": 10,
|
ev_soc_miss=10,
|
||||||
"ac_charge_break_even": 1.0,
|
ac_charge_break_even=1.0,
|
||||||
},
|
),
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": "Penalty parameters used in fitness evaluation.",
|
"description": "Penalty parameters used in fitness evaluation.",
|
||||||
"examples": [{"ev_soc_miss": 10}],
|
"examples": [{"ev_soc_miss": 10}],
|
||||||
|
|||||||
@@ -141,7 +141,11 @@ class Genetic0VisualizationReport(ConfigMixin):
|
|||||||
marker = markers[idx] if markers and idx < len(markers) else "o" # Marker style
|
marker = markers[idx] if markers and idx < len(markers) else "o" # Marker style
|
||||||
line_style = line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
line_style = line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
||||||
plt.plot(
|
plt.plot(
|
||||||
timestamps, y_data, label=label, marker=marker, linestyle=line_style
|
mdates.date2num(timestamps),
|
||||||
|
np.asarray(y_data, dtype=float),
|
||||||
|
label=label,
|
||||||
|
marker=marker,
|
||||||
|
linestyle=line_style,
|
||||||
) # Plot line
|
) # Plot line
|
||||||
|
|
||||||
# Format the time axis
|
# Format the time axis
|
||||||
@@ -178,8 +182,15 @@ class Genetic0VisualizationReport(ConfigMixin):
|
|||||||
# Add vertical line for the current date if within the axis range
|
# Add vertical line for the current date if within the axis range
|
||||||
current_time = pendulum.now(self.config.general.timezone)
|
current_time = pendulum.now(self.config.general.timezone)
|
||||||
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
|
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
|
||||||
plt.axvline(current_time, color="r", linestyle="--", label="Now")
|
plt.axvline(mdates.date2num(current_time), color="r", linestyle="--", label="Now")
|
||||||
plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom")
|
plt.text(
|
||||||
|
mdates.date2num(current_time),
|
||||||
|
plt.ylim()[1],
|
||||||
|
"Now",
|
||||||
|
color="r",
|
||||||
|
ha="center",
|
||||||
|
va="bottom",
|
||||||
|
)
|
||||||
|
|
||||||
# Add a second x-axis on top
|
# Add a second x-axis on top
|
||||||
ax1 = plt.gca()
|
ax1 = plt.gca()
|
||||||
@@ -191,7 +202,9 @@ class Genetic0VisualizationReport(ConfigMixin):
|
|||||||
# ax2.set_xticks(timestamps[::48]) # Set ticks every 12 hours
|
# 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_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_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks
|
||||||
ax2.set_xticks(timestamps[:: len(timestamps) // 12]) # Select 10 evenly spaced ticks
|
ax2.set_xticks(
|
||||||
|
mdates.date2num(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) // 24]])
|
||||||
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 12]])
|
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 12]])
|
||||||
if x2label:
|
if x2label:
|
||||||
@@ -251,7 +264,13 @@ class Genetic0VisualizationReport(ConfigMixin):
|
|||||||
line_style = (
|
line_style = (
|
||||||
line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
||||||
) # Line style
|
) # Line style
|
||||||
plt.plot(x, y_data, label=label, marker=marker, linestyle=line_style) # Plot line
|
plt.plot(
|
||||||
|
x,
|
||||||
|
np.asarray(y_data, dtype=float),
|
||||||
|
label=label,
|
||||||
|
marker=marker,
|
||||||
|
linestyle=line_style,
|
||||||
|
) # Plot line
|
||||||
|
|
||||||
plt.title(title) # Set title
|
plt.title(title) # Set title
|
||||||
plt.xlabel(xlabel) # Set x-axis label
|
plt.xlabel(xlabel) # Set x-axis label
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class ElecFeeDataRecord(PredictionRecord):
|
|||||||
return self.elecfee_feedin_amt_wh * 1000.0
|
return self.elecfee_feedin_amt_wh * 1000.0
|
||||||
|
|
||||||
|
|
||||||
class ElecFeeProvider(PredictionProvider):
|
class ElecFeeProvider(PredictionProvider[ElecFeeDataRecord]):
|
||||||
"""Abstract base class for electricity fee providers.
|
"""Abstract base class for electricity fee providers.
|
||||||
|
|
||||||
Electricity fee providers predict fees on consumed and feed-in electricity to be used by
|
Electricity fee providers predict fees on consumed and feed-in electricity to be used by
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class ElecPriceDataRecord(PredictionRecord):
|
|||||||
return self.elecprice_marketprice_wh * 1000.0
|
return self.elecprice_marketprice_wh * 1000.0
|
||||||
|
|
||||||
|
|
||||||
class ElecPriceProvider(PricePredictionProviderBase):
|
class ElecPriceProvider(PricePredictionProviderBase[ElecPriceDataRecord]):
|
||||||
"""Abstract base class for electricity price providers.
|
"""Abstract base class for electricity price providers.
|
||||||
|
|
||||||
ElecPriceProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
ElecPriceProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class FeedInTariffDataRecord(PredictionRecord):
|
|||||||
return self.feed_in_tariff_wh * 1000.0
|
return self.feed_in_tariff_wh * 1000.0
|
||||||
|
|
||||||
|
|
||||||
class FeedInTariffProvider(PricePredictionProviderBase):
|
class FeedInTariffProvider(PricePredictionProviderBase[FeedInTariffDataRecord]):
|
||||||
"""Abstract base class for feed in tariff providers.
|
"""Abstract base class for feed in tariff providers.
|
||||||
|
|
||||||
FeedInTariffProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
FeedInTariffProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||||
|
|||||||
@@ -123,10 +123,10 @@ class FeedInTariffAkkudoktor(FeedInTariffProvider):
|
|||||||
history = np.asarray(
|
history = np.asarray(
|
||||||
await self.key_to_array(
|
await self.key_to_array(
|
||||||
key="feed_in_tariff_wh",
|
key="feed_in_tariff_wh",
|
||||||
end_datetime=self.highest_orig_datetime,
|
end_datetime=to_datetime(self.highest_orig_datetime),
|
||||||
fill_method="linear",
|
fill_method="linear",
|
||||||
),
|
),
|
||||||
dtype=float,
|
dtype=np.float64,
|
||||||
)
|
)
|
||||||
covered_hours = (
|
covered_hours = (
|
||||||
int((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) + 1
|
int((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) + 1
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
|||||||
# above, so ETS/median always trains on the true wholesale-price signal.
|
# above, so ETS/median always trains on the true wholesale-price signal.
|
||||||
history = await self.key_to_array(
|
history = await self.key_to_array(
|
||||||
key="feed_in_tariff_raw_wh",
|
key="feed_in_tariff_raw_wh",
|
||||||
end_datetime=self.highest_orig_datetime,
|
end_datetime=to_datetime(self.highest_orig_datetime),
|
||||||
interval=to_duration(f"{resolution_seconds} seconds"),
|
interval=to_duration(f"{resolution_seconds} seconds"),
|
||||||
fill_method="linear",
|
fill_method="linear",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -137,11 +137,11 @@ class FeedInTariffTibber(FeedInTariffProvider):
|
|||||||
history = np.asarray(
|
history = np.asarray(
|
||||||
await self.key_to_array(
|
await self.key_to_array(
|
||||||
key="feed_in_tariff_wh",
|
key="feed_in_tariff_wh",
|
||||||
end_datetime=self.highest_orig_datetime,
|
end_datetime=to_datetime(self.highest_orig_datetime),
|
||||||
interval=to_duration(f"{interval_seconds} seconds"),
|
interval=to_duration(f"{interval_seconds} seconds"),
|
||||||
fill_method="linear",
|
fill_method="linear",
|
||||||
),
|
),
|
||||||
dtype=float,
|
dtype=np.float64,
|
||||||
)
|
)
|
||||||
covered_slots = 0
|
covered_slots = 0
|
||||||
if self.highest_orig_datetime >= self.ems_start_datetime:
|
if self.highest_orig_datetime >= self.ems_start_datetime:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Notes:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import List, Optional
|
from typing import Generic, List, Optional, TypeVar
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
@@ -20,7 +20,10 @@ class LoadDataRecord(PredictionRecord):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoadProvider(PredictionProvider):
|
LoadDataRecordT = TypeVar("LoadDataRecordT", bound=LoadDataRecord)
|
||||||
|
|
||||||
|
|
||||||
|
class LoadProvider(PredictionProvider[LoadDataRecordT], Generic[LoadDataRecordT]):
|
||||||
"""Abstract base class for load providers.
|
"""Abstract base class for load providers.
|
||||||
|
|
||||||
LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||||
@@ -41,7 +44,7 @@ class LoadProvider(PredictionProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# overload
|
# overload
|
||||||
records: List[LoadDataRecord] = Field(
|
records: List[LoadDataRecordT] = Field(
|
||||||
default_factory=list, json_schema_extra={"description": "List of LoadDataRecord records"}
|
default_factory=list, json_schema_extra={"description": "List of LoadDataRecord records"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class LoadAkkudoktorDataRecord(LoadDataRecord):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoadAkkudoktor(LoadProvider):
|
class LoadAkkudoktor(LoadProvider[LoadAkkudoktorDataRecord]):
|
||||||
"""Fetch Load forecast data from Akkudoktor load profiles."""
|
"""Fetch Load forecast data from Akkudoktor load profiles."""
|
||||||
|
|
||||||
records: list[LoadAkkudoktorDataRecord] = Field(
|
records: list[LoadAkkudoktorDataRecord] = Field(
|
||||||
|
|||||||
@@ -119,40 +119,42 @@ weather_openmeteo = WeatherOpenMeteo()
|
|||||||
weather_import = WeatherImport()
|
weather_import = WeatherImport()
|
||||||
|
|
||||||
|
|
||||||
def prediction_providers() -> list[
|
PredictionProviderType = Union[
|
||||||
Union[
|
ElecFeeFixed,
|
||||||
ElecFeeFixed,
|
ElecFeeImport,
|
||||||
ElecFeeImport,
|
ElecPriceAkkudoktor,
|
||||||
ElecPriceAkkudoktor,
|
ElecPriceEnergyCharts,
|
||||||
ElecPriceEnergyCharts,
|
ElecPriceFixed,
|
||||||
ElecPriceFixed,
|
ElecPriceImport,
|
||||||
ElecPriceImport,
|
ElecPriceSMARD,
|
||||||
ElecPriceSMARD,
|
ElecPriceTibber,
|
||||||
ElecPriceTibber,
|
FeedInTariffAkkudoktor,
|
||||||
FeedInTariffAkkudoktor,
|
FeedInTariffDvhubOnline,
|
||||||
FeedInTariffDvhubOnline,
|
FeedInTariffEnergyCharts,
|
||||||
FeedInTariffEnergyCharts,
|
FeedInTariffFixed,
|
||||||
FeedInTariffFixed,
|
FeedInTariffImport,
|
||||||
FeedInTariffImport,
|
FeedInTariffSMARD,
|
||||||
FeedInTariffSMARD,
|
FeedInTariffTibber,
|
||||||
FeedInTariffTibber,
|
LoadAkkudoktor,
|
||||||
LoadAkkudoktor,
|
LoadAkkudoktorAdjusted,
|
||||||
LoadAkkudoktorAdjusted,
|
LoadImport,
|
||||||
LoadImport,
|
LoadVrm,
|
||||||
LoadVrm,
|
PVForecastAkkudoktor,
|
||||||
PVForecastAkkudoktor,
|
PVForecastForecastSolar,
|
||||||
PVForecastForecastSolar,
|
PVForecastHomeAssistant,
|
||||||
PVForecastImport,
|
PVForecastImport,
|
||||||
PVForecastPVLib,
|
PVForecastPVLib,
|
||||||
PVForecastPVNode,
|
PVForecastPVNode,
|
||||||
PVForecastSolcast,
|
PVForecastSolcast,
|
||||||
PVForecastVrm,
|
PVForecastVrm,
|
||||||
WeatherBrightSky,
|
WeatherBrightSky,
|
||||||
WeatherClearOutside,
|
WeatherClearOutside,
|
||||||
WeatherImport,
|
WeatherImport,
|
||||||
WeatherOpenMeteo,
|
WeatherOpenMeteo,
|
||||||
]
|
]
|
||||||
]:
|
|
||||||
|
|
||||||
|
def prediction_providers() -> list[PredictionProviderType]:
|
||||||
"""Return list of prediction providers.
|
"""Return list of prediction providers.
|
||||||
|
|
||||||
Factory for prediction container.
|
Factory for prediction container.
|
||||||
@@ -229,44 +231,10 @@ def prediction_providers() -> list[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class Prediction(PredictionContainer):
|
class Prediction(PredictionContainer[PredictionProviderType]):
|
||||||
"""Prediction container to manage multiple prediction providers."""
|
"""Prediction container to manage multiple prediction providers."""
|
||||||
|
|
||||||
providers: list[
|
providers: list[PredictionProviderType] = Field(
|
||||||
Union[
|
|
||||||
ElecFeeFixed,
|
|
||||||
ElecFeeImport,
|
|
||||||
ElecPriceAkkudoktor,
|
|
||||||
ElecPriceEnergyCharts,
|
|
||||||
ElecPriceFixed,
|
|
||||||
ElecPriceImport,
|
|
||||||
ElecPriceSMARD,
|
|
||||||
ElecPriceTibber,
|
|
||||||
FeedInTariffAkkudoktor,
|
|
||||||
FeedInTariffDvhubOnline,
|
|
||||||
FeedInTariffEnergyCharts,
|
|
||||||
FeedInTariffFixed,
|
|
||||||
FeedInTariffImport,
|
|
||||||
FeedInTariffSMARD,
|
|
||||||
FeedInTariffTibber,
|
|
||||||
LoadAkkudoktor,
|
|
||||||
LoadAkkudoktorAdjusted,
|
|
||||||
LoadImport,
|
|
||||||
LoadVrm,
|
|
||||||
PVForecastAkkudoktor,
|
|
||||||
PVForecastForecastSolar,
|
|
||||||
PVForecastHomeAssistant,
|
|
||||||
PVForecastImport,
|
|
||||||
PVForecastPVLib,
|
|
||||||
PVForecastPVNode,
|
|
||||||
PVForecastSolcast,
|
|
||||||
PVForecastVrm,
|
|
||||||
WeatherBrightSky,
|
|
||||||
WeatherClearOutside,
|
|
||||||
WeatherImport,
|
|
||||||
WeatherOpenMeteo,
|
|
||||||
]
|
|
||||||
] = Field(
|
|
||||||
default_factory=prediction_providers,
|
default_factory=prediction_providers,
|
||||||
json_schema_extra={"description": "List of prediction providers"},
|
json_schema_extra={"description": "List of prediction providers"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This module is designed for use in predictive modeling workflows, facilitating t
|
|||||||
and manipulation of configuration and prediction data in a clear, scalable, and structured manner.
|
and manipulation of configuration and prediction data in a clear, scalable, and structured manner.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import Generic, List, Optional, TypeVar
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field, computed_field
|
from pydantic import Field, computed_field
|
||||||
@@ -20,6 +20,7 @@ from akkudoktoreos.core.dataabc import (
|
|||||||
DataImportProvider,
|
DataImportProvider,
|
||||||
DataProvider,
|
DataProvider,
|
||||||
DataRecord,
|
DataRecord,
|
||||||
|
DataRecordT,
|
||||||
DataSequence,
|
DataSequence,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.utils.datetimeutil import DateTime, Duration, to_duration
|
from akkudoktoreos.utils.datetimeutil import DateTime, Duration, to_duration
|
||||||
@@ -52,7 +53,10 @@ class PredictionRecord(DataRecord):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PredictionSequence(DataSequence):
|
PredictionRecordT = TypeVar("PredictionRecordT", bound=PredictionRecord)
|
||||||
|
|
||||||
|
|
||||||
|
class PredictionSequence(DataSequence[PredictionRecordT], Generic[PredictionRecordT]):
|
||||||
"""A managed sequence of PredictionRecord instances with list-like behavior.
|
"""A managed sequence of PredictionRecord instances with list-like behavior.
|
||||||
|
|
||||||
The PredictionSequence class provides an ordered, mutable collection of PredictionRecord
|
The PredictionSequence class provides an ordered, mutable collection of PredictionRecord
|
||||||
@@ -90,7 +94,7 @@ class PredictionSequence(DataSequence):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# To be overloaded by derived classes.
|
# To be overloaded by derived classes.
|
||||||
records: List[PredictionRecord] = Field(
|
records: List[PredictionRecordT] = Field(
|
||||||
default_factory=list, json_schema_extra={"description": "List of prediction records"}
|
default_factory=list, json_schema_extra={"description": "List of prediction records"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,7 +189,9 @@ class PredictionStartEndKeepMixin(PredictionABC):
|
|||||||
return int(duration.total_hours())
|
return int(duration.total_hours())
|
||||||
|
|
||||||
|
|
||||||
class PredictionProvider(PredictionStartEndKeepMixin, DataProvider):
|
class PredictionProvider(
|
||||||
|
PredictionStartEndKeepMixin, DataProvider[DataRecordT], Generic[DataRecordT]
|
||||||
|
):
|
||||||
"""Abstract base class for prediction providers with singleton thread-safety and configurable prediction parameters.
|
"""Abstract base class for prediction providers with singleton thread-safety and configurable prediction parameters.
|
||||||
|
|
||||||
This class serves as a base for managing prediction data, providing an interface for derived
|
This class serves as a base for managing prediction data, providing an interface for derived
|
||||||
@@ -249,7 +255,9 @@ class PredictionProvider(PredictionStartEndKeepMixin, DataProvider):
|
|||||||
await self._update_data(force_update=force_update)
|
await self._update_data(force_update=force_update)
|
||||||
|
|
||||||
|
|
||||||
class PredictionImportProvider(PredictionProvider, DataImportProvider):
|
class PredictionImportProvider(
|
||||||
|
PredictionProvider[DataRecordT], DataImportProvider[DataRecordT], Generic[DataRecordT]
|
||||||
|
):
|
||||||
"""Abstract base class for prediction providers that import prediction data.
|
"""Abstract base class for prediction providers that import prediction data.
|
||||||
|
|
||||||
This class is designed to handle prediction data provided in the form of a key-value dictionary.
|
This class is designed to handle prediction data provided in the form of a key-value dictionary.
|
||||||
@@ -264,7 +272,12 @@ class PredictionImportProvider(PredictionProvider, DataImportProvider):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PredictionContainer(PredictionStartEndKeepMixin, DataContainer):
|
PredictionProviderT = TypeVar("PredictionProviderT", bound=PredictionProvider)
|
||||||
|
|
||||||
|
|
||||||
|
class PredictionContainer(
|
||||||
|
PredictionStartEndKeepMixin, DataContainer[PredictionProviderT], Generic[PredictionProviderT]
|
||||||
|
):
|
||||||
"""A container for managing multiple PredictionProvider instances.
|
"""A container for managing multiple PredictionProvider instances.
|
||||||
|
|
||||||
This class enables access to data from multiple prediction providers, supporting retrieval and
|
This class enables access to data from multiple prediction providers, supporting retrieval and
|
||||||
@@ -277,6 +290,6 @@ class PredictionContainer(PredictionStartEndKeepMixin, DataContainer):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# To be overloaded by derived classes.
|
# To be overloaded by derived classes.
|
||||||
providers: List[PredictionProvider] = Field(
|
providers: List[PredictionProviderT] = Field(
|
||||||
default_factory=list, json_schema_extra={"description": "List of prediction providers"}
|
default_factory=list, json_schema_extra={"description": "List of prediction providers"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Shared base for price-like predictions (electricity price, feed-in tariff)."""
|
"""Shared base for price-like predictions (electricity price, feed-in tariff)."""
|
||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import cast
|
from typing import Generic, cast
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -9,11 +9,14 @@ from loguru import logger
|
|||||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import PredictionMixin
|
from akkudoktoreos.core.coreabc import PredictionMixin
|
||||||
|
from akkudoktoreos.core.dataabc import DataRecordT
|
||||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider
|
from akkudoktoreos.prediction.predictionabc import PredictionProvider
|
||||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
|
||||||
|
|
||||||
|
|
||||||
class PricePredictionProviderBase(PredictionMixin, PredictionProvider):
|
class PricePredictionProviderBase(
|
||||||
|
PredictionMixin, PredictionProvider[DataRecordT], Generic[DataRecordT]
|
||||||
|
):
|
||||||
"""Common forecasting + fee-application logic shared by price-like providers.
|
"""Common forecasting + fee-application logic shared by price-like providers.
|
||||||
|
|
||||||
Subclasses must supply the raw/gross record keys, the fee keys to pull from
|
Subclasses must supply the raw/gross record keys, the fee keys to pull from
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Notes:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import List, Optional
|
from typing import Generic, List, Optional, TypeVar
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@@ -24,7 +24,10 @@ class PVForecastDataRecord(PredictionRecord):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PVForecastProvider(PredictionProvider):
|
PVForecastDataRecordT = TypeVar("PVForecastDataRecordT", bound=PVForecastDataRecord)
|
||||||
|
|
||||||
|
|
||||||
|
class PVForecastProvider(PredictionProvider[PVForecastDataRecordT], Generic[PVForecastDataRecordT]):
|
||||||
"""Abstract base class for pvforecast providers.
|
"""Abstract base class for pvforecast providers.
|
||||||
|
|
||||||
PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||||
@@ -45,7 +48,7 @@ class PVForecastProvider(PredictionProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# overload
|
# overload
|
||||||
records: List[PVForecastDataRecord] = Field(
|
records: List[PVForecastDataRecordT] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
json_schema_extra={"description": "List of PVForecastDataRecord records"},
|
json_schema_extra={"description": "List of PVForecastDataRecord records"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ class PVForecastAkkudoktorDataRecord(PVForecastDataRecord):
|
|||||||
return self.pvforecast_ac_power
|
return self.pvforecast_ac_power
|
||||||
|
|
||||||
|
|
||||||
class PVForecastAkkudoktor(PVForecastProvider):
|
class PVForecastAkkudoktor(PVForecastProvider[PVForecastAkkudoktorDataRecord]):
|
||||||
"""Fetch and process PV forecast data from akkudoktor.net.
|
"""Fetch and process PV forecast data from akkudoktor.net.
|
||||||
|
|
||||||
PVForecastAkkudoktor is a singleton-based class that retrieves weather forecast data
|
PVForecastAkkudoktor is a singleton-based class that retrieves weather forecast data
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ class PVForecastForecastSolar(PVForecastProvider):
|
|||||||
return to_datetime(s)
|
return to_datetime(s)
|
||||||
tz = iana_tz or str(self.config.general.timezone)
|
tz = iana_tz or str(self.config.general.timezone)
|
||||||
dt = pendulum.parse(s, tz=tz)
|
dt = pendulum.parse(s, tz=tz)
|
||||||
|
if not isinstance(dt, pendulum.DateTime):
|
||||||
|
raise ValueError(f"Expected a datetime, got {local_ts!r}")
|
||||||
return to_datetime(dt.isoformat())
|
return to_datetime(dt.isoformat())
|
||||||
|
|
||||||
def _plane_url(self, plane: Any) -> str:
|
def _plane_url(self, plane: Any) -> str:
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class PVForecastPVNode(PVForecastProvider):
|
|||||||
tz = iana_tz or str(self.config.general.timezone)
|
tz = iana_tz or str(self.config.general.timezone)
|
||||||
# Interpret the naive wall-clock string AS local time in tz, then resolve.
|
# Interpret the naive wall-clock string AS local time in tz, then resolve.
|
||||||
dt = pendulum.parse(s, tz=tz)
|
dt = pendulum.parse(s, tz=tz)
|
||||||
|
if not isinstance(dt, pendulum.DateTime):
|
||||||
|
raise ValueError(f"Expected a datetime, got {local_ts!r}")
|
||||||
return to_datetime(dt.isoformat())
|
return to_datetime(dt.isoformat())
|
||||||
|
|
||||||
def _extract_values(self, body: Any) -> list[tuple[Any, float]]:
|
def _extract_values(self, body: Any) -> list[tuple[Any, float]]:
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class WeatherDataRecord(PredictionRecord):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class WeatherProvider(PredictionProvider):
|
class WeatherProvider(PredictionProvider[WeatherDataRecord]):
|
||||||
"""Abstract base class for weather providers.
|
"""Abstract base class for weather providers.
|
||||||
|
|
||||||
WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||||
|
|||||||
@@ -246,12 +246,15 @@ class WeatherBrightSky(WeatherProvider):
|
|||||||
logger.debug(debug_msg)
|
logger.debug(debug_msg)
|
||||||
return
|
return
|
||||||
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
|
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
|
||||||
|
end_datetime = self.end_datetime
|
||||||
|
if end_datetime is None:
|
||||||
|
raise ValueError("Prediction end datetime is not available")
|
||||||
pwat = pd.Series(
|
pwat = pd.Series(
|
||||||
data=data,
|
data=data,
|
||||||
index=pd.DatetimeIndex(
|
index=pd.DatetimeIndex(
|
||||||
pd.date_range(
|
pd.date_range(
|
||||||
start=self.ems_start_datetime,
|
start=self.ems_start_datetime,
|
||||||
end=self.end_datetime,
|
end=end_datetime,
|
||||||
freq="1h",
|
freq="1h",
|
||||||
inclusive="left",
|
inclusive="left",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Notes:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import requests
|
import requests
|
||||||
@@ -232,7 +232,7 @@ class WeatherClearOutside(WeatherProvider):
|
|||||||
p_detail_tables.pop(0)
|
p_detail_tables.pop(0)
|
||||||
|
|
||||||
# Create clearout data
|
# Create clearout data
|
||||||
clearout_data = {}
|
clearout_data: dict[str, Any] = {}
|
||||||
# Number of detail values. On last day may be less than 24.
|
# Number of detail values. On last day may be less than 24.
|
||||||
detail_values_count = None
|
detail_values_count = None
|
||||||
# Add data values
|
# Add data values
|
||||||
@@ -258,7 +258,7 @@ class WeatherClearOutside(WeatherProvider):
|
|||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
# Scrape the detail values
|
# Scrape the detail values
|
||||||
detail_data = []
|
detail_data: list[float | str] = []
|
||||||
extra_detail_name = None
|
extra_detail_name = None
|
||||||
extra_detail_data = []
|
extra_detail_data = []
|
||||||
for p_detail_value in p_detail_values:
|
for p_detail_value in p_detail_values:
|
||||||
@@ -281,9 +281,10 @@ class WeatherClearOutside(WeatherProvider):
|
|||||||
and hasattr(p_detail_value, "title")
|
and hasattr(p_detail_value, "title")
|
||||||
and p_detail_value.title
|
and p_detail_value.title
|
||||||
):
|
):
|
||||||
value_str = p_detail_value.title.string
|
value_str = p_detail_value.title.get_text()
|
||||||
else:
|
else:
|
||||||
value_str = p_detail_value.get_text()
|
value_str = p_detail_value.get_text()
|
||||||
|
value: float | str
|
||||||
try:
|
try:
|
||||||
value = float(value_str)
|
value = float(value_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -336,9 +337,9 @@ class WeatherClearOutside(WeatherProvider):
|
|||||||
if key is None:
|
if key is None:
|
||||||
continue
|
continue
|
||||||
if detail_name in clearout_data:
|
if detail_name in clearout_data:
|
||||||
value = clearout_data[detail_name][row_index]
|
record_value = clearout_data[detail_name][row_index]
|
||||||
corr_factor = clearoutside_key_mapping[detail_name][1]
|
corr_factor = clearoutside_key_mapping[detail_name][1]
|
||||||
if corr_factor:
|
if corr_factor:
|
||||||
value = value * corr_factor
|
record_value = record_value * corr_factor
|
||||||
setattr(weather_record, key, value)
|
setattr(weather_record, key, record_value)
|
||||||
await self.insert_by_datetime(weather_record)
|
await self.insert_by_datetime(weather_record)
|
||||||
|
|||||||
@@ -342,12 +342,15 @@ class WeatherOpenMeteo(WeatherProvider):
|
|||||||
return
|
return
|
||||||
|
|
||||||
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
|
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
|
||||||
|
end_datetime = self.end_datetime
|
||||||
|
if end_datetime is None:
|
||||||
|
raise ValueError("Prediction end datetime is not available")
|
||||||
pwat = pd.Series(
|
pwat = pd.Series(
|
||||||
data=data,
|
data=data,
|
||||||
index=pd.DatetimeIndex(
|
index=pd.DatetimeIndex(
|
||||||
pd.date_range(
|
pd.date_range(
|
||||||
start=self.ems_start_datetime,
|
start=self.ems_start_datetime,
|
||||||
end=self.end_datetime,
|
end=end_datetime,
|
||||||
freq="1h",
|
freq="1h",
|
||||||
inclusive="left",
|
inclusive="left",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
# Module taken from https://github.com/koaning/fh-altair
|
# Module taken from https://github.com/koaning/fh-altair
|
||||||
# MIT license
|
# MIT license
|
||||||
from typing import Optional
|
from typing import Callable, Optional, cast
|
||||||
|
|
||||||
from bokeh.embed import components
|
from bokeh.embed import components
|
||||||
from bokeh.models import Plot
|
from bokeh.models import Plot
|
||||||
|
from bokeh.models.annotations import Title
|
||||||
|
from bokeh.plotting import figure
|
||||||
from bokeh.resources import INLINE
|
from bokeh.resources import INLINE
|
||||||
from monsterui.franken import H4, Card, NotStr
|
from monsterui.franken import H4, Card, NotStr
|
||||||
|
|
||||||
|
# Bokeh accepts FigureOptions as constructor keywords, but its generated
|
||||||
|
# constructor signature only lists model properties. Preserve the typed result.
|
||||||
|
create_figure = cast(Callable[..., figure], figure)
|
||||||
|
|
||||||
# Javascript for bokeh - to be included by the page
|
# Javascript for bokeh - to be included by the page
|
||||||
BokehJS = [NotStr(INLINE.render_css()), NotStr(INLINE.render_js())]
|
BokehJS = [NotStr(INLINE.render_css()), NotStr(INLINE.render_js())]
|
||||||
|
|
||||||
@@ -29,7 +35,7 @@ def bokey_apply_theme_to_plot(plot: Plot, dark: bool) -> None:
|
|||||||
if dark:
|
if dark:
|
||||||
plot.background_fill_color = "#1e1e1e"
|
plot.background_fill_color = "#1e1e1e"
|
||||||
plot.border_fill_color = "#1e1e1e"
|
plot.border_fill_color = "#1e1e1e"
|
||||||
plot.title.text_color = "white"
|
cast(Title, plot.title).text_color = "white"
|
||||||
for ax in plot.xaxis + plot.yaxis:
|
for ax in plot.xaxis + plot.yaxis:
|
||||||
ax.axis_line_color = "white"
|
ax.axis_line_color = "white"
|
||||||
ax.major_tick_line_color = "white"
|
ax.major_tick_line_color = "white"
|
||||||
@@ -44,7 +50,7 @@ def bokey_apply_theme_to_plot(plot: Plot, dark: bool) -> None:
|
|||||||
else:
|
else:
|
||||||
plot.background_fill_color = "white"
|
plot.background_fill_color = "white"
|
||||||
plot.border_fill_color = "white"
|
plot.border_fill_color = "white"
|
||||||
plot.title.text_color = "black"
|
cast(Title, plot.title).text_color = "black"
|
||||||
for ax in plot.xaxis + plot.yaxis:
|
for ax in plot.xaxis + plot.yaxis:
|
||||||
ax.axis_line_color = "black"
|
ax.axis_line_color = "black"
|
||||||
ax.major_tick_line_color = "black"
|
ax.major_tick_line_color = "black"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def item_model_defaults(item_model: Any) -> tuple[dict, list[str]]:
|
|||||||
if field_info.default is not PydanticUndefined:
|
if field_info.default is not PydanticUndefined:
|
||||||
kwargs[field_name] = field_info.default
|
kwargs[field_name] = field_info.default
|
||||||
elif field_info.default_factory is not None:
|
elif field_info.default_factory is not None:
|
||||||
kwargs[field_name] = field_info.default_factory()
|
kwargs[field_name] = field_info.get_default(call_default_factory=True)
|
||||||
else:
|
else:
|
||||||
required_missing.append(field_name)
|
required_missing.append(field_name)
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_f
|
|||||||
"""
|
"""
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
if not regular_field:
|
if not regular_field or not isinstance(field_info, FieldInfo):
|
||||||
return "N/A"
|
return "N/A"
|
||||||
|
|
||||||
# Resolve the raw default — prefer plain default, fall back to factory
|
# Resolve the raw default — prefer plain default, fall back to factory
|
||||||
@@ -200,7 +200,7 @@ def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_f
|
|||||||
val = field_info.default
|
val = field_info.default
|
||||||
elif field_info.default_factory is not None:
|
elif field_info.default_factory is not None:
|
||||||
try:
|
try:
|
||||||
val = field_info.default_factory()
|
val = field_info.get_default(call_default_factory=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
else:
|
else:
|
||||||
@@ -250,12 +250,12 @@ def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple
|
|||||||
|
|
||||||
|
|
||||||
def create_config_details(
|
def create_config_details(
|
||||||
model: type[PydanticBaseModel], values: dict, values_prefix: list[str] = []
|
model: type[PydanticBaseModel] | type[ConfigEOS], values: dict, values_prefix: list[str] = []
|
||||||
) -> dict[str, dict]:
|
) -> dict[str, dict]:
|
||||||
"""Generate configuration details based on provided values and model metadata.
|
"""Generate configuration details based on provided values and model metadata.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model (type[PydanticBaseModel]): The Pydantic model to extract configuration from.
|
model: An EOS model or the top-level settings class to extract configuration from.
|
||||||
values (dict): A dictionary containing the current configuration values.
|
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.
|
values_prefix (list[str]): A list of parent type names that prefixes the model values in the values.
|
||||||
|
|
||||||
@@ -271,7 +271,11 @@ def create_config_details(
|
|||||||
) -> None:
|
) -> None:
|
||||||
nonlocal values, values_prefix
|
nonlocal values, values_prefix
|
||||||
regular_field = isinstance(subfield_info, FieldInfo)
|
regular_field = isinstance(subfield_info, FieldInfo)
|
||||||
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
|
subtype = (
|
||||||
|
subfield_info.annotation
|
||||||
|
if isinstance(subfield_info, FieldInfo)
|
||||||
|
else subfield_info.return_type
|
||||||
|
)
|
||||||
|
|
||||||
nested_types = resolve_nested_types(subtype, [])
|
nested_types = resolve_nested_types(subtype, [])
|
||||||
found_basic = False
|
found_basic = False
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fasthtml.common import FT, Div, NotStr
|
|||||||
from markdown_it import MarkdownIt
|
from markdown_it import MarkdownIt
|
||||||
from markdown_it.renderer import RendererHTML
|
from markdown_it.renderer import RendererHTML
|
||||||
from markdown_it.token import Token
|
from markdown_it.token import Token
|
||||||
|
from markdown_it.utils import OptionsDict
|
||||||
from monsterui.foundations import stringify
|
from monsterui.foundations import stringify
|
||||||
|
|
||||||
# Where to find the static data assets
|
# Where to find the static data assets
|
||||||
@@ -42,7 +43,7 @@ def file_to_data_uri(file_path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def render_heading(
|
def render_heading(
|
||||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
self: RendererHTML, tokens: List[Token], idx: int, options: OptionsDict, env: dict
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Custom renderer for Markdown headings with MonsterUI styling."""
|
"""Custom renderer for Markdown headings with MonsterUI styling."""
|
||||||
if tokens[idx].markup == "#":
|
if tokens[idx].markup == "#":
|
||||||
@@ -63,7 +64,7 @@ def render_heading(
|
|||||||
|
|
||||||
|
|
||||||
def render_paragraph(
|
def render_paragraph(
|
||||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
self: RendererHTML, tokens: List[Token], idx: int, options: OptionsDict, env: dict
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Custom renderer for Markdown paragraphs with MonsterUI styling."""
|
"""Custom renderer for Markdown paragraphs with MonsterUI styling."""
|
||||||
tokens[idx].attrSet("class", "leading-7 [&:not(:first-child)]:mt-6")
|
tokens[idx].attrSet("class", "leading-7 [&:not(:first-child)]:mt-6")
|
||||||
@@ -71,28 +72,30 @@ def render_paragraph(
|
|||||||
|
|
||||||
|
|
||||||
def render_blockquote(
|
def render_blockquote(
|
||||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
self: RendererHTML, tokens: List[Token], idx: int, options: OptionsDict, env: dict
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Custom renderer for Markdown blockquotes with MonsterUI styling."""
|
"""Custom renderer for Markdown blockquotes with MonsterUI styling."""
|
||||||
tokens[idx].attrSet("class", "mt-6 border-l-2 pl-6 italic border-primary")
|
tokens[idx].attrSet("class", "mt-6 border-l-2 pl-6 italic border-primary")
|
||||||
return self.renderToken(tokens, idx, options, env)
|
return self.renderToken(tokens, idx, options, env)
|
||||||
|
|
||||||
|
|
||||||
def render_list(self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict) -> str:
|
def render_list(
|
||||||
|
self: RendererHTML, tokens: List[Token], idx: int, options: OptionsDict, env: dict
|
||||||
|
) -> str:
|
||||||
"""Custom renderer for lists with MonsterUI styling."""
|
"""Custom renderer for lists with MonsterUI styling."""
|
||||||
tokens[idx].attrSet("class", "my-6 ml-6 list-disc [&>li]:mt-2")
|
tokens[idx].attrSet("class", "my-6 ml-6 list-disc [&>li]:mt-2")
|
||||||
return self.renderToken(tokens, idx, options, env)
|
return self.renderToken(tokens, idx, options, env)
|
||||||
|
|
||||||
|
|
||||||
def render_image(
|
def render_image(
|
||||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
self: RendererHTML, tokens: List[Token], idx: int, options: OptionsDict, env: dict
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Custom renderer for Markdown images with MonsterUI styling."""
|
"""Custom renderer for Markdown images with MonsterUI styling."""
|
||||||
token = tokens[idx]
|
token = tokens[idx]
|
||||||
src = token.attrGet("src")
|
src = token.attrGet("src")
|
||||||
alt = token.content or ""
|
alt = token.content or ""
|
||||||
|
|
||||||
if src:
|
if isinstance(src, str) and src:
|
||||||
pos = src.find(ASSETS_PREFIX)
|
pos = src.find(ASSETS_PREFIX)
|
||||||
if pos != -1:
|
if pos != -1:
|
||||||
asset_rel = src[pos + len(ASSETS_PREFIX) :]
|
asset_rel = src[pos + len(ASSETS_PREFIX) :]
|
||||||
@@ -107,12 +110,14 @@ def render_image(
|
|||||||
return self.renderToken(tokens, idx, options, env)
|
return self.renderToken(tokens, idx, options, env)
|
||||||
|
|
||||||
|
|
||||||
def render_link(self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict) -> str:
|
def render_link(
|
||||||
|
self: RendererHTML, tokens: List[Token], idx: int, options: OptionsDict, env: dict
|
||||||
|
) -> str:
|
||||||
"""Custom renderer for Markdown links with MonsterUI styling."""
|
"""Custom renderer for Markdown links with MonsterUI styling."""
|
||||||
token = tokens[idx]
|
token = tokens[idx]
|
||||||
href = token.attrGet("href")
|
href = token.attrGet("href")
|
||||||
|
|
||||||
if href:
|
if isinstance(href, str) and href:
|
||||||
pos = href.find(ASSETS_PREFIX)
|
pos = href.find(ASSETS_PREFIX)
|
||||||
if pos != -1:
|
if pos != -1:
|
||||||
asset_rel = href[pos + len(ASSETS_PREFIX) :]
|
asset_rel = href[pos + len(ASSETS_PREFIX) :]
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from typing import Optional, Union
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import requests
|
import requests
|
||||||
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
|
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
|
||||||
from bokeh.plotting import figure
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from monsterui.franken import (
|
from monsterui.franken import (
|
||||||
Card,
|
Card,
|
||||||
@@ -28,7 +27,11 @@ from akkudoktoreos.core.emplan import (
|
|||||||
OMBCInstruction,
|
OMBCInstruction,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.optimization.optimization import OptimizationSolution
|
from akkudoktoreos.optimization.optimization import OptimizationSolution
|
||||||
from akkudoktoreos.server.dash.bokeh import Bokeh, bokey_apply_theme_to_plot
|
from akkudoktoreos.server.dash.bokeh import (
|
||||||
|
Bokeh,
|
||||||
|
bokey_apply_theme_to_plot,
|
||||||
|
create_figure,
|
||||||
|
)
|
||||||
from akkudoktoreos.server.dash.components import Error
|
from akkudoktoreos.server.dash.components import Error
|
||||||
from akkudoktoreos.server.dash.context import request_url_for
|
from akkudoktoreos.server.dash.context import request_url_for
|
||||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
||||||
@@ -259,21 +262,21 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
|
|||||||
last_run_datetime = "unknown"
|
last_run_datetime = "unknown"
|
||||||
start_datetime = "unknown"
|
start_datetime = "unknown"
|
||||||
|
|
||||||
plot = figure(
|
plot = create_figure(
|
||||||
title=f"Optimization Solution - last run: {last_run_datetime}",
|
title=f"Optimization Solution - last run: {last_run_datetime}",
|
||||||
x_axis_type="datetime",
|
x_axis_type="datetime",
|
||||||
x_axis_label=f"Datetime [localtime {date_time_tz}] - start: {start_datetime}",
|
x_axis_label=f"Datetime [localtime {date_time_tz}] - start: {start_datetime}",
|
||||||
y_axis_label="Power [W]",
|
y_axis_label="Power [W]",
|
||||||
sizing_mode="stretch_width",
|
sizing_mode="stretch_width",
|
||||||
y_range=Range1d(power_w_min, power_w_max),
|
y_range=Range1d(start=power_w_min, end=power_w_max),
|
||||||
height=400,
|
height=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
plot.extra_y_ranges = {
|
plot.extra_y_ranges = {
|
||||||
"energy": Range1d(energy_wh_min, energy_wh_max), # y2
|
"energy": Range1d(start=energy_wh_min, end=energy_wh_max), # y2
|
||||||
"factor": Range1d(factor_min, factor_max), # y3
|
"factor": Range1d(start=factor_min, end=factor_max), # y3
|
||||||
"amt_kwh": Range1d(amt_kwh_min, amt_kwh_max), # y4
|
"amt_kwh": Range1d(start=amt_kwh_min, end=amt_kwh_max), # y4
|
||||||
"amt": Range1d(amt_min, amt_max), # y5
|
"amt": Range1d(start=amt_min, end=amt_max), # y5
|
||||||
}
|
}
|
||||||
# y2 axis
|
# y2 axis
|
||||||
y2_axis = LinearAxis(y_range_name="energy", axis_label="Energy [Wh]")
|
y2_axis = LinearAxis(y_range_name="energy", axis_label="Energy [Wh]")
|
||||||
@@ -536,7 +539,7 @@ def InstructionCard(
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
# This is a battery
|
# This is a battery
|
||||||
if instruction.operation_mode_id in ("CHARGE",):
|
if getattr(instruction, "operation_mode_id", None) in ("CHARGE",):
|
||||||
icon = "battery-charging"
|
icon = "battery-charging"
|
||||||
else:
|
else:
|
||||||
icon = "battery"
|
icon = "battery"
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ from typing import Optional, Union
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import requests
|
import requests
|
||||||
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
|
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
|
||||||
from bokeh.plotting import figure
|
|
||||||
from monsterui.franken import FT, Grid, P
|
from monsterui.franken import FT, Grid, P
|
||||||
|
|
||||||
from akkudoktoreos.core.pydantic import PydanticDateTimeSeries
|
from akkudoktoreos.core.pydantic import PydanticDateTimeSeries
|
||||||
from akkudoktoreos.server.dash.bokeh import Bokeh, bokey_apply_theme_to_plot
|
from akkudoktoreos.server.dash.bokeh import (
|
||||||
|
Bokeh,
|
||||||
|
bokey_apply_theme_to_plot,
|
||||||
|
create_figure,
|
||||||
|
)
|
||||||
from akkudoktoreos.server.dash.components import Error
|
from akkudoktoreos.server.dash.components import Error
|
||||||
|
|
||||||
# bar width for 15 minutes bars (time given in millseconds)
|
# bar width for 15 minutes bars (time given in millseconds)
|
||||||
@@ -18,7 +21,7 @@ def PVForecast(predictions: pd.DataFrame, config: dict, date_time_tz: str, dark:
|
|||||||
source = ColumnDataSource(predictions)
|
source = ColumnDataSource(predictions)
|
||||||
provider = config["pvforecast"]["provider"]
|
provider = config["pvforecast"]["provider"]
|
||||||
|
|
||||||
plot = figure(
|
plot = create_figure(
|
||||||
x_axis_type="datetime",
|
x_axis_type="datetime",
|
||||||
title=f"PV Power Prediction ({provider})",
|
title=f"PV Power Prediction ({provider})",
|
||||||
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
||||||
@@ -46,7 +49,7 @@ def ElectricityPriceForecast(
|
|||||||
source = ColumnDataSource(predictions)
|
source = ColumnDataSource(predictions)
|
||||||
provider = config["elecprice"]["provider"]
|
provider = config["elecprice"]["provider"]
|
||||||
|
|
||||||
plot = figure(
|
plot = create_figure(
|
||||||
x_axis_type="datetime",
|
x_axis_type="datetime",
|
||||||
y_range=Range1d(
|
y_range=Range1d(
|
||||||
predictions["elecprice_marketprice_kwh"].min() - 0.1,
|
predictions["elecprice_marketprice_kwh"].min() - 0.1,
|
||||||
@@ -78,7 +81,7 @@ def WeatherTempAirHumidityForecast(
|
|||||||
source = ColumnDataSource(predictions)
|
source = ColumnDataSource(predictions)
|
||||||
provider = config["weather"]["provider"]
|
provider = config["weather"]["provider"]
|
||||||
|
|
||||||
plot = figure(
|
plot = create_figure(
|
||||||
x_axis_type="datetime",
|
x_axis_type="datetime",
|
||||||
title=f"Air Temperature and Humidity Prediction ({provider})",
|
title=f"Air Temperature and Humidity Prediction ({provider})",
|
||||||
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
||||||
@@ -115,7 +118,7 @@ def WeatherIrradianceForecast(
|
|||||||
source = ColumnDataSource(predictions)
|
source = ColumnDataSource(predictions)
|
||||||
provider = config["weather"]["provider"]
|
provider = config["weather"]["provider"]
|
||||||
|
|
||||||
plot = figure(
|
plot = create_figure(
|
||||||
x_axis_type="datetime",
|
x_axis_type="datetime",
|
||||||
title=f"Irradiance Prediction ({provider})",
|
title=f"Irradiance Prediction ({provider})",
|
||||||
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
||||||
@@ -157,7 +160,7 @@ def LoadForecast(predictions: pd.DataFrame, config: dict, date_time_tz: str, dar
|
|||||||
year_energy = config["load"]["loadakkudoktor"]["loadakkudoktor_year_energy_kwh"]
|
year_energy = config["load"]["loadakkudoktor"]["loadakkudoktor_year_energy_kwh"]
|
||||||
provider = f"{provider}, {year_energy} kWh"
|
provider = f"{provider}, {year_energy} kWh"
|
||||||
|
|
||||||
plot = figure(
|
plot = create_figure(
|
||||||
title=f"Load Prediction ({provider})",
|
title=f"Load Prediction ({provider})",
|
||||||
x_axis_type="datetime",
|
x_axis_type="datetime",
|
||||||
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from akkudoktoreos.core.coreabc import (
|
|||||||
get_resource_registry,
|
get_resource_registry,
|
||||||
singletons_init,
|
singletons_init,
|
||||||
)
|
)
|
||||||
|
from akkudoktoreos.core.dataabc import DataImportMixin
|
||||||
from akkudoktoreos.core.emplan import EnergyManagementPlan, ResourceStatus
|
from akkudoktoreos.core.emplan import EnergyManagementPlan, ResourceStatus
|
||||||
from akkudoktoreos.core.ems import ems_manage_energy
|
from akkudoktoreos.core.ems import ems_manage_energy
|
||||||
from akkudoktoreos.core.emsettings import EnergyManagementMode
|
from akkudoktoreos.core.emsettings import EnergyManagementMode
|
||||||
@@ -88,7 +89,12 @@ from akkudoktoreos.server.server import (
|
|||||||
get_host_ip,
|
get_host_ip,
|
||||||
wait_for_port_free,
|
wait_for_port_free,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import (
|
||||||
|
DateTime,
|
||||||
|
Duration,
|
||||||
|
to_datetime,
|
||||||
|
to_duration,
|
||||||
|
)
|
||||||
|
|
||||||
# ----------------------
|
# ----------------------
|
||||||
# EOS REST Server
|
# EOS REST Server
|
||||||
@@ -671,6 +677,8 @@ async def fastapi_logging_get_log(
|
|||||||
"""
|
"""
|
||||||
log_path = get_config().logging.file_path
|
log_path = get_config().logging.file_path
|
||||||
try:
|
try:
|
||||||
|
if log_path is None:
|
||||||
|
raise ValueError("Log file path is not configured")
|
||||||
logs = read_file_log(
|
logs = read_file_log(
|
||||||
log_path=log_path,
|
log_path=log_path,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -864,16 +872,16 @@ async def fastapi_measurement_series_get(
|
|||||||
if processing == SeriesProcessing.RAW:
|
if processing == SeriesProcessing.RAW:
|
||||||
pdseries = await get_measurement().key_to_raw_series(
|
pdseries = await get_measurement().key_to_raw_series(
|
||||||
key=key,
|
key=key,
|
||||||
start_datetime=start_datetime,
|
start_datetime=to_datetime(start_datetime) if start_datetime is not None else None,
|
||||||
end_datetime=end_datetime,
|
end_datetime=to_datetime(end_datetime) if end_datetime is not None else None,
|
||||||
dropna=dropna,
|
dropna=dropna,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
pdseries = await get_measurement().key_to_series(
|
pdseries = await get_measurement().key_to_series(
|
||||||
key=key,
|
key=key,
|
||||||
start_datetime=start_datetime,
|
start_datetime=to_datetime(start_datetime) if start_datetime is not None else None,
|
||||||
end_datetime=end_datetime,
|
end_datetime=to_datetime(end_datetime) if end_datetime is not None else None,
|
||||||
interval=interval,
|
interval=to_duration(interval) if interval is not None else None,
|
||||||
fill_method=fill_method,
|
fill_method=fill_method,
|
||||||
resample_method=resample_method,
|
resample_method=resample_method,
|
||||||
dropna=dropna,
|
dropna=dropna,
|
||||||
@@ -1247,6 +1255,9 @@ async def fastapi_prediction_series_get(
|
|||||||
Returns:
|
Returns:
|
||||||
Array
|
Array
|
||||||
"""
|
"""
|
||||||
|
resolved_end_datetime: DateTime | None
|
||||||
|
resolved_interval: Duration
|
||||||
|
resolved_start_datetime: DateTime | None
|
||||||
if key not in get_prediction().record_keys:
|
if key not in get_prediction().record_keys:
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=404,
|
status=404,
|
||||||
@@ -1255,10 +1266,10 @@ async def fastapi_prediction_series_get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if start_datetime is None:
|
if start_datetime is None:
|
||||||
start_datetime = get_prediction().ems_start_datetime
|
resolved_start_datetime = get_prediction().ems_start_datetime
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
start_datetime = to_datetime(start_datetime)
|
resolved_start_datetime = to_datetime(start_datetime)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=400,
|
status=400,
|
||||||
@@ -1268,10 +1279,10 @@ async def fastapi_prediction_series_get(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
if end_datetime is None:
|
if end_datetime is None:
|
||||||
end_datetime = get_prediction().end_datetime
|
resolved_end_datetime = get_prediction().end_datetime
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
end_datetime = to_datetime(end_datetime)
|
resolved_end_datetime = to_datetime(end_datetime)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=400,
|
status=400,
|
||||||
@@ -1281,10 +1292,10 @@ async def fastapi_prediction_series_get(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
if interval is None:
|
if interval is None:
|
||||||
interval = to_duration("1 hour")
|
resolved_interval = to_duration("1 hour")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
interval = to_duration(interval)
|
resolved_interval = to_duration(interval)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=400,
|
status=400,
|
||||||
@@ -1297,16 +1308,16 @@ async def fastapi_prediction_series_get(
|
|||||||
if processing == SeriesProcessing.RAW:
|
if processing == SeriesProcessing.RAW:
|
||||||
pdseries = await get_prediction().key_to_raw_series(
|
pdseries = await get_prediction().key_to_raw_series(
|
||||||
key=key,
|
key=key,
|
||||||
start_datetime=start_datetime,
|
start_datetime=resolved_start_datetime,
|
||||||
end_datetime=end_datetime,
|
end_datetime=resolved_end_datetime,
|
||||||
dropna=dropna,
|
dropna=dropna,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
pdseries = await get_prediction().key_to_series(
|
pdseries = await get_prediction().key_to_series(
|
||||||
key=key,
|
key=key,
|
||||||
start_datetime=start_datetime,
|
start_datetime=resolved_start_datetime,
|
||||||
end_datetime=end_datetime,
|
end_datetime=resolved_end_datetime,
|
||||||
interval=interval,
|
interval=resolved_interval,
|
||||||
fill_method=fill_method,
|
fill_method=fill_method,
|
||||||
resample_method=resample_method,
|
resample_method=resample_method,
|
||||||
dropna=dropna,
|
dropna=dropna,
|
||||||
@@ -1417,24 +1428,26 @@ async def fastapi_prediction_dataframe_get(
|
|||||||
forecast or reporting queries where alignment to the exact query window is
|
forecast or reporting queries where alignment to the exact query window is
|
||||||
more important than clock-round boundaries.
|
more important than clock-round boundaries.
|
||||||
"""
|
"""
|
||||||
|
resolved_end_datetime: DateTime | None
|
||||||
|
resolved_start_datetime: DateTime | None
|
||||||
for key in keys:
|
for key in keys:
|
||||||
if key not in get_prediction().record_keys:
|
if key not in get_prediction().record_keys:
|
||||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||||
if start_datetime is None:
|
if start_datetime is None:
|
||||||
start_datetime = get_prediction().ems_start_datetime
|
resolved_start_datetime = get_prediction().ems_start_datetime
|
||||||
else:
|
else:
|
||||||
start_datetime = to_datetime(start_datetime)
|
resolved_start_datetime = to_datetime(start_datetime)
|
||||||
if end_datetime is None:
|
if end_datetime is None:
|
||||||
end_datetime = get_prediction().end_datetime
|
resolved_end_datetime = get_prediction().end_datetime
|
||||||
else:
|
else:
|
||||||
end_datetime = to_datetime(end_datetime)
|
resolved_end_datetime = to_datetime(end_datetime)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
prediction_df = await get_prediction().keys_to_dataframe(
|
prediction_df = await get_prediction().keys_to_dataframe(
|
||||||
keys=keys,
|
keys=keys,
|
||||||
start_datetime=start_datetime,
|
start_datetime=resolved_start_datetime,
|
||||||
end_datetime=end_datetime,
|
end_datetime=resolved_end_datetime,
|
||||||
interval=interval,
|
interval=to_duration(interval) if interval is not None else None,
|
||||||
fill_method=fill_method,
|
fill_method=fill_method,
|
||||||
resample_method=resample_method,
|
resample_method=resample_method,
|
||||||
dropna=dropna,
|
dropna=dropna,
|
||||||
@@ -1536,6 +1549,9 @@ async def fastapi_prediction_list_get(
|
|||||||
forecast or reporting queries where alignment to the exact query window is
|
forecast or reporting queries where alignment to the exact query window is
|
||||||
more important than clock-round boundaries.
|
more important than clock-round boundaries.
|
||||||
"""
|
"""
|
||||||
|
resolved_end_datetime: DateTime | None
|
||||||
|
resolved_interval: Duration
|
||||||
|
resolved_start_datetime: DateTime | None
|
||||||
if key not in get_prediction().record_keys:
|
if key not in get_prediction().record_keys:
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=404,
|
status=404,
|
||||||
@@ -1544,10 +1560,10 @@ async def fastapi_prediction_list_get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if start_datetime is None:
|
if start_datetime is None:
|
||||||
start_datetime = get_prediction().ems_start_datetime
|
resolved_start_datetime = get_prediction().ems_start_datetime
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
start_datetime = to_datetime(start_datetime)
|
resolved_start_datetime = to_datetime(start_datetime)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=400,
|
status=400,
|
||||||
@@ -1557,10 +1573,10 @@ async def fastapi_prediction_list_get(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
if end_datetime is None:
|
if end_datetime is None:
|
||||||
end_datetime = get_prediction().end_datetime
|
resolved_end_datetime = get_prediction().end_datetime
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
end_datetime = to_datetime(end_datetime)
|
resolved_end_datetime = to_datetime(end_datetime)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=400,
|
status=400,
|
||||||
@@ -1570,10 +1586,10 @@ async def fastapi_prediction_list_get(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
if interval is None:
|
if interval is None:
|
||||||
interval = to_duration("1 hour")
|
resolved_interval = to_duration("1 hour")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
interval = to_duration(interval)
|
resolved_interval = to_duration(interval)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=400,
|
status=400,
|
||||||
@@ -1585,9 +1601,9 @@ async def fastapi_prediction_list_get(
|
|||||||
try:
|
try:
|
||||||
prediction_array = await get_prediction().key_to_array(
|
prediction_array = await get_prediction().key_to_array(
|
||||||
key=key,
|
key=key,
|
||||||
start_datetime=start_datetime,
|
start_datetime=resolved_start_datetime,
|
||||||
end_datetime=end_datetime,
|
end_datetime=resolved_end_datetime,
|
||||||
interval=interval,
|
interval=resolved_interval,
|
||||||
fill_method=fill_method,
|
fill_method=fill_method,
|
||||||
resample_method=resample_method,
|
resample_method=resample_method,
|
||||||
dropna=dropna,
|
dropna=dropna,
|
||||||
@@ -1655,6 +1671,12 @@ async def fastapi_prediction_import_provider(
|
|||||||
cause=e,
|
cause=e,
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
if not isinstance(provider, DataImportMixin):
|
||||||
|
raise EOSProblem(
|
||||||
|
status=400,
|
||||||
|
title="Prediction import failed",
|
||||||
|
detail=f"Provider '{provider_id}' does not support data imports.",
|
||||||
|
)
|
||||||
await provider.import_from_json(json_str=json_str)
|
await provider.import_from_json(json_str=json_str)
|
||||||
provider.update_datetime = to_datetime(in_timezone=get_config().general.timezone)
|
provider.update_datetime = to_datetime(in_timezone=get_config().general.timezone)
|
||||||
|
|
||||||
@@ -2192,8 +2214,8 @@ async def fastapi_optimize(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create compatible solution.
|
# Create compatible solution.
|
||||||
legacy_solution = Genetic0SolutionLegacy(
|
legacy_solution = Genetic0SolutionLegacy.model_validate(
|
||||||
**{
|
{
|
||||||
"ac_charge": solution.ac_charge,
|
"ac_charge": solution.ac_charge,
|
||||||
"dc_charge": solution.dc_charge,
|
"dc_charge": solution.dc_charge,
|
||||||
"discharge_allowed": solution.discharge_allowed,
|
"discharge_allowed": solution.discharge_allowed,
|
||||||
@@ -2396,7 +2418,7 @@ def run_eos() -> None:
|
|||||||
port=config_eos.server.port,
|
port=config_eos.server.port,
|
||||||
log_level=uv_log_level,
|
log_level=uv_log_level,
|
||||||
access_log=True, # Fix server access logging to True
|
access_log=True, # Fix server access logging to True
|
||||||
reload=config_eos.server.reload,
|
reload=bool(config_eos.server.reload),
|
||||||
proxy_headers=True,
|
proxy_headers=True,
|
||||||
forwarded_allow_ips="*",
|
forwarded_allow_ips="*",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import html
|
import html
|
||||||
import traceback
|
import traceback
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||||
@@ -54,7 +55,9 @@ def _problem_response(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def eos_problem_handler(request: Request, exc: EOSProblem) -> JSONResponse:
|
async def eos_problem_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
# Starlette dispatches this handler by the registered exception class.
|
||||||
|
exc = cast(EOSProblem, exc)
|
||||||
return _problem_response(
|
return _problem_response(
|
||||||
request=request,
|
request=request,
|
||||||
status=exc.status,
|
status=exc.status,
|
||||||
@@ -65,12 +68,14 @@ async def eos_problem_handler(request: Request, exc: EOSProblem) -> JSONResponse
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
# Starlette dispatches this handler by the registered exception class.
|
||||||
|
http_exc = cast(HTTPException, exc)
|
||||||
return _problem_response(
|
return _problem_response(
|
||||||
request=request,
|
request=request,
|
||||||
status=exc.status_code,
|
status=http_exc.status_code,
|
||||||
title="HTTP Error",
|
title="HTTP Error",
|
||||||
detail=str(exc.detail),
|
detail=str(http_exc.detail),
|
||||||
cause=exc,
|
cause=exc,
|
||||||
type="about:blank",
|
type="about:blank",
|
||||||
)
|
)
|
||||||
@@ -87,7 +92,9 @@ async def unexpected_exception_handler(request: Request, exc: Exception) -> JSON
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
async def validation_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
# Starlette dispatches this handler by the registered exception class.
|
||||||
|
exc = cast(RequestValidationError, exc)
|
||||||
return _problem_response(
|
return _problem_response(
|
||||||
request=request,
|
request=request,
|
||||||
status=422,
|
status=422,
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, MutableMapping, Optional
|
from typing import TYPE_CHECKING, Any, MutableMapping, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from loguru import Record
|
||||||
|
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import get_config
|
from akkudoktoreos.core.coreabc import get_config
|
||||||
from akkudoktoreos.server.server import (
|
from akkudoktoreos.server.server import (
|
||||||
validate_ip_or_hostname,
|
validate_ip_or_hostname,
|
||||||
@@ -99,7 +103,7 @@ def _emit_drop_warning() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def patch_loguru_record(
|
def patch_loguru_record(
|
||||||
record: MutableMapping[str, Any],
|
record: "Record | MutableMapping[str, Any]",
|
||||||
*,
|
*,
|
||||||
file_name: str,
|
file_name: str,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
|
|||||||
@@ -124,7 +124,12 @@ def wait_for_port_free(port: int, timeout: int = 0, waiting_app_name: str = "App
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
for conn in psutil.net_connections(kind="inet"):
|
for conn in psutil.net_connections(kind="inet"):
|
||||||
if conn.laddr.port == port and conn.pid not in seen_pids:
|
if (
|
||||||
|
conn.laddr
|
||||||
|
and conn.laddr.port == port
|
||||||
|
and conn.pid is not None
|
||||||
|
and conn.pid not in seen_pids
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
process = psutil.Process(conn.pid)
|
process = psutil.Process(conn.pid)
|
||||||
seen_pids.add(conn.pid)
|
seen_pids.add(conn.pid)
|
||||||
|
|||||||
@@ -46,22 +46,36 @@ See each function's docstring for detailed argument options and examples.
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
from typing import Any, List, Literal, Optional, Tuple, Union, overload
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
List,
|
||||||
|
Literal,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
Union,
|
||||||
|
cast,
|
||||||
|
overload,
|
||||||
|
)
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pendulum import UTC as UTC
|
||||||
from pendulum.tz.timezone import Timezone
|
from pendulum.tz.timezone import Timezone
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
GetCoreSchemaHandler,
|
GetCoreSchemaHandler,
|
||||||
)
|
)
|
||||||
from pydantic_core import core_schema
|
from pydantic_core import core_schema
|
||||||
from pydantic_extra_types.pendulum_dt import ( # make pendulum types pydantic
|
|
||||||
Date,
|
|
||||||
DateTime,
|
|
||||||
Duration,
|
|
||||||
)
|
|
||||||
from tzfpy import get_tz
|
from tzfpy import get_tz
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# The Pydantic adapters validate Pendulum values; arithmetic and factory
|
||||||
|
# functions return the base types rather than the validation subclasses.
|
||||||
|
from pendulum import Date, DateTime, Duration
|
||||||
|
else:
|
||||||
|
from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration
|
||||||
|
|
||||||
MAX_DURATION_STRING_LENGTH = 350
|
MAX_DURATION_STRING_LENGTH = 350
|
||||||
|
|
||||||
|
|
||||||
@@ -184,7 +198,7 @@ class Time(pendulum.Time):
|
|||||||
# Bypass __init__ and __new__ by directly casting the type
|
# Bypass __init__ and __new__ by directly casting the type
|
||||||
time_obj.__class__ = cls # This is safe since Time inherits from pendulum.Time
|
time_obj.__class__ = cls # This is safe since Time inherits from pendulum.Time
|
||||||
|
|
||||||
return time_obj
|
return cast(Time, time_obj)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _serialize(cls, value: Optional["Time"]) -> str:
|
def _serialize(cls, value: Optional["Time"]) -> str:
|
||||||
@@ -224,7 +238,7 @@ class Time(pendulum.Time):
|
|||||||
if self.tzinfo and other.tzinfo:
|
if self.tzinfo and other.tzinfo:
|
||||||
# Convert both to UTC for comparison
|
# Convert both to UTC for comparison
|
||||||
self_utc = self.in_timezone("UTC")
|
self_utc = self.in_timezone("UTC")
|
||||||
other_utc = other.in_timezone("UTC")
|
other_utc = cast(Time, other).in_timezone("UTC")
|
||||||
return (self_utc.hour, self_utc.minute, self_utc.second, self_utc.microsecond) == (
|
return (self_utc.hour, self_utc.minute, self_utc.second, self_utc.microsecond) == (
|
||||||
other_utc.hour,
|
other_utc.hour,
|
||||||
other_utc.minute,
|
other_utc.minute,
|
||||||
@@ -259,7 +273,9 @@ class Time(pendulum.Time):
|
|||||||
"""Convert to UTC timezone."""
|
"""Convert to UTC timezone."""
|
||||||
return self.in_timezone("UTC")
|
return self.in_timezone("UTC")
|
||||||
|
|
||||||
def in_timezone(self, timezone: Union[str, pendulum.Timezone]) -> "Time":
|
def in_timezone(
|
||||||
|
self, timezone: Union[str, pendulum.Timezone, pendulum.FixedTimezone]
|
||||||
|
) -> "Time":
|
||||||
"""Convert to specified timezone."""
|
"""Convert to specified timezone."""
|
||||||
if isinstance(timezone, str):
|
if isinstance(timezone, str):
|
||||||
timezone = pendulum.timezone(timezone)
|
timezone = pendulum.timezone(timezone)
|
||||||
@@ -267,7 +283,9 @@ class Time(pendulum.Time):
|
|||||||
if self.is_aware():
|
if self.is_aware():
|
||||||
# For timezone conversion, we need a reference date
|
# For timezone conversion, we need a reference date
|
||||||
# Use today's date as reference
|
# Use today's date as reference
|
||||||
today = pendulum.today(self.tzinfo)
|
today = cast(Callable[[datetime.tzinfo | None], pendulum.DateTime], pendulum.today)(
|
||||||
|
self.tzinfo
|
||||||
|
)
|
||||||
dt = today.at(self.hour, self.minute, self.second, self.microsecond)
|
dt = today.at(self.hour, self.minute, self.second, self.microsecond)
|
||||||
dt = dt.in_timezone(timezone) # Convert to target timezone
|
dt = dt.in_timezone(timezone) # Convert to target timezone
|
||||||
t = dt.time() # Extract naiv time component
|
t = dt.time() # Extract naiv time component
|
||||||
@@ -316,7 +334,7 @@ class Time(pendulum.Time):
|
|||||||
return self.format(time_format)
|
return self.format(time_format)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def now(cls, tz: Union[str, pendulum.Timezone] = None) -> "Time":
|
def now(cls, tz: Union[str, pendulum.Timezone, None] = None) -> "Time":
|
||||||
"""Get current time with optional timezone."""
|
"""Get current time with optional timezone."""
|
||||||
if tz:
|
if tz:
|
||||||
if isinstance(tz, str):
|
if isinstance(tz, str):
|
||||||
@@ -336,7 +354,7 @@ class Time(pendulum.Time):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_time_string(time_str: str, default_date: pendulum.Date = None) -> pendulum.Time:
|
def _parse_time_string(time_str: str, default_date: pendulum.Date | None = None) -> pendulum.Time:
|
||||||
"""Parse various time string formats with comprehensive patterns and timezone support.
|
"""Parse various time string formats with comprehensive patterns and timezone support.
|
||||||
|
|
||||||
Supports a wide variety of time formats including:
|
Supports a wide variety of time formats including:
|
||||||
@@ -387,7 +405,7 @@ def _parse_time_string(time_str: str, default_date: pendulum.Date = None) -> pen
|
|||||||
raise ValueError("Empty time string")
|
raise ValueError("Empty time string")
|
||||||
|
|
||||||
# Extract timezone information first
|
# Extract timezone information first
|
||||||
timezone_info = None
|
timezone_info: pendulum.Timezone | pendulum.FixedTimezone | None = None
|
||||||
time_part = time_str
|
time_part = time_str
|
||||||
|
|
||||||
# Pattern for timezone at the end: +HH:MM, -HH:MM, +HHMM, -HHMM, UTC, GMT, EST, PST, etc.
|
# Pattern for timezone at the end: +HH:MM, -HH:MM, +HHMM, -HHMM, UTC, GMT, EST, PST, etc.
|
||||||
@@ -703,7 +721,9 @@ def to_time(
|
|||||||
# Convert from original timezone to selected timezone
|
# Convert from original timezone to selected timezone
|
||||||
# For timezone conversion, we need a reference date
|
# For timezone conversion, we need a reference date
|
||||||
# Use today's date as reference
|
# Use today's date as reference
|
||||||
today = pendulum.today(t.tzinfo)
|
today = cast(
|
||||||
|
Callable[[datetime.tzinfo | None], pendulum.DateTime], pendulum.today
|
||||||
|
)(t.tzinfo)
|
||||||
dt = today.at(t.hour, t.minute, t.second, t.microsecond)
|
dt = today.at(t.hour, t.minute, t.second, t.microsecond)
|
||||||
dt = dt.in_timezone(timezone) # Convert to target timezone
|
dt = dt.in_timezone(timezone) # Convert to target timezone
|
||||||
t = dt.time() # Extract time component (always naive)
|
t = dt.time() # Extract time component (always naive)
|
||||||
@@ -746,7 +766,7 @@ def to_time(
|
|||||||
tz_name = value.tzinfo.tzname(value)
|
tz_name = value.tzinfo.tzname(value)
|
||||||
# Safely get Pendulum timezone
|
# Safely get Pendulum timezone
|
||||||
try:
|
try:
|
||||||
timezone = pendulum.timezone(tz_name)
|
timezone = pendulum.timezone(cast(str, tz_name))
|
||||||
except Exception:
|
except Exception:
|
||||||
# fallback to fixed offset if tz_name is something like 'UTC+02:00'
|
# fallback to fixed offset if tz_name is something like 'UTC+02:00'
|
||||||
utc_offset = value.tzinfo.utcoffset(value)
|
utc_offset = value.tzinfo.utcoffset(value)
|
||||||
@@ -754,7 +774,7 @@ def to_time(
|
|||||||
utc_offset_total_seconds = 0.0
|
utc_offset_total_seconds = 0.0
|
||||||
else:
|
else:
|
||||||
utc_offset_total_seconds = utc_offset.total_seconds()
|
utc_offset_total_seconds = utc_offset.total_seconds()
|
||||||
timezone = pendulum.FixedTimezone(utc_offset_total_seconds // 60)
|
timezone = pendulum.FixedTimezone(int(utc_offset_total_seconds // 60))
|
||||||
pdt = pendulum.instance(value).in_tz(timezone)
|
pdt = pendulum.instance(value).in_tz(timezone)
|
||||||
return finalize(pdt.time())
|
return finalize(pdt.time())
|
||||||
|
|
||||||
@@ -792,21 +812,25 @@ def to_time(
|
|||||||
|
|
||||||
# Fallback to pendulum's parser
|
# Fallback to pendulum's parser
|
||||||
try:
|
try:
|
||||||
dt = pendulum.parse(value, strict=False).in_tz(timezone)
|
dt = cast(pendulum.DateTime, pendulum.parse(value, strict=False)).in_tz(timezone)
|
||||||
return finalize(dt.time())
|
return finalize(dt.time())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.trace(f"Pendulum parser failed for '{value}': {e}")
|
logger.trace(f"Pendulum parser failed for '{value}': {e}")
|
||||||
|
|
||||||
# Try parsing with ISO time prefix
|
# Try parsing with ISO time prefix
|
||||||
try:
|
try:
|
||||||
dt = pendulum.parse(f"T{value}", strict=False).in_tz(timezone)
|
dt = cast(pendulum.DateTime, pendulum.parse(f"T{value}", strict=False)).in_tz(
|
||||||
|
timezone
|
||||||
|
)
|
||||||
return finalize(dt.time())
|
return finalize(dt.time())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.trace(f"ISO time parser failed for 'T{value}': {e}")
|
logger.trace(f"ISO time parser failed for 'T{value}': {e}")
|
||||||
|
|
||||||
# Try parsing as part of a full datetime
|
# Try parsing as part of a full datetime
|
||||||
try:
|
try:
|
||||||
dt = pendulum.parse(f"2000-01-01 {value}", strict=False).in_tz(timezone)
|
dt = cast(
|
||||||
|
pendulum.DateTime, pendulum.parse(f"2000-01-01 {value}", strict=False)
|
||||||
|
).in_tz(timezone)
|
||||||
return finalize(dt.time())
|
return finalize(dt.time())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.trace(f"Full datetime parser failed for '2000-01-01 {value}': {e}")
|
logger.trace(f"Full datetime parser failed for '2000-01-01 {value}': {e}")
|
||||||
@@ -903,16 +927,19 @@ def to_datetime(
|
|||||||
'2024-10-31 12:00:00'
|
'2024-10-31 12:00:00'
|
||||||
"""
|
"""
|
||||||
# Timezone to convert to
|
# Timezone to convert to
|
||||||
|
timezone: Timezone | pendulum.FixedTimezone
|
||||||
if in_timezone is None:
|
if in_timezone is None:
|
||||||
in_timezone = pendulum.local_timezone()
|
timezone = pendulum.local_timezone()
|
||||||
elif not isinstance(in_timezone, Timezone):
|
elif isinstance(in_timezone, Timezone):
|
||||||
in_timezone = pendulum.timezone(in_timezone)
|
timezone = in_timezone
|
||||||
|
else:
|
||||||
|
timezone = pendulum.timezone(in_timezone)
|
||||||
|
|
||||||
if isinstance(date_input, DateTime):
|
if isinstance(date_input, DateTime):
|
||||||
dt = date_input
|
dt = date_input
|
||||||
elif isinstance(date_input, Date):
|
elif isinstance(date_input, Date):
|
||||||
dt = pendulum.datetime(
|
dt = pendulum.datetime(
|
||||||
year=date_input.year, month=date_input.month, day=date_input.day, tz=in_timezone
|
year=date_input.year, month=date_input.month, day=date_input.day, tz=timezone
|
||||||
)
|
)
|
||||||
if to_maxtime:
|
if to_maxtime:
|
||||||
dt = dt.end_of("day")
|
dt = dt.end_of("day")
|
||||||
@@ -937,10 +964,10 @@ def to_datetime(
|
|||||||
# DateTime input without timezone info
|
# DateTime input without timezone info
|
||||||
try:
|
try:
|
||||||
fmt_tz = f"{fmt} z"
|
fmt_tz = f"{fmt} z"
|
||||||
dt_tz = f"{date_input} {in_timezone}"
|
dt_tz = f"{date_input} {timezone}"
|
||||||
dt = pendulum.from_format(dt_tz, fmt_tz)
|
dt = pendulum.from_format(dt_tz, fmt_tz)
|
||||||
logger.trace(
|
logger.trace(
|
||||||
f"Str Fmt converted: {dt}, tz={dt.tz} from {date_input}, tz={in_timezone}"
|
f"Str Fmt converted: {dt}, tz={dt.tz} from {date_input}, tz={timezone}"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -949,9 +976,9 @@ def to_datetime(
|
|||||||
else:
|
else:
|
||||||
# DateTime input with timezone info
|
# DateTime input with timezone info
|
||||||
try:
|
try:
|
||||||
dt = pendulum.parse(date_input)
|
dt = cast(pendulum.DateTime, pendulum.parse(date_input))
|
||||||
logger.trace(
|
logger.trace(
|
||||||
f"Pendulum Fmt converted: {dt}, tz={dt.tz} from {date_input}, tz={in_timezone}"
|
f"Pendulum Fmt converted: {dt}, tz={dt.tz} from {date_input}, tz={timezone}"
|
||||||
)
|
)
|
||||||
except pendulum.parsing.exceptions.ParserError as e:
|
except pendulum.parsing.exceptions.ParserError as e:
|
||||||
logger.trace(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}")
|
||||||
@@ -971,7 +998,9 @@ def to_datetime(
|
|||||||
if dt is None:
|
if dt is None:
|
||||||
raise ValueError(f"Date string {date_input} does not match any known formats.")
|
raise ValueError(f"Date string {date_input} does not match any known formats.")
|
||||||
elif date_input is None:
|
elif date_input is None:
|
||||||
dt = pendulum.now(tz=in_timezone)
|
dt = cast(Callable[[Timezone | pendulum.FixedTimezone], pendulum.DateTime], pendulum.now)(
|
||||||
|
timezone
|
||||||
|
)
|
||||||
elif isinstance(date_input, datetime.datetime):
|
elif isinstance(date_input, datetime.datetime):
|
||||||
dt = pendulum.instance(date_input)
|
dt = pendulum.instance(date_input)
|
||||||
elif isinstance(date_input, datetime.date):
|
elif isinstance(date_input, datetime.date):
|
||||||
@@ -988,10 +1017,14 @@ def to_datetime(
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
# Every supported input branch produces a datetime or raises above.
|
||||||
|
if dt is None:
|
||||||
|
raise ValueError("Datetime conversion did not produce a value")
|
||||||
|
|
||||||
# Represent in target timezone
|
# Represent in target timezone
|
||||||
dt_in_tz = dt.in_timezone(in_timezone)
|
dt_in_tz = dt.in_timezone(timezone)
|
||||||
logger.trace(
|
logger.trace(
|
||||||
f"\nTimezone adapted to: {in_timezone}\nfrom: {dt} tz={dt.timezone}\nto: {dt_in_tz} tz={dt_in_tz.tz}"
|
f"\nTimezone adapted to: {timezone}\nfrom: {dt} tz={dt.timezone}\nto: {dt_in_tz} tz={dt_in_tz.tz}"
|
||||||
)
|
)
|
||||||
dt = dt_in_tz
|
dt = dt_in_tz
|
||||||
|
|
||||||
@@ -1158,7 +1191,8 @@ def to_duration(
|
|||||||
duration = parsed # Already a duration
|
duration = parsed # Already a duration
|
||||||
else:
|
else:
|
||||||
# It's a DateTime, calculate duration from start of day
|
# It's a DateTime, calculate duration from start of day
|
||||||
duration = parsed - parsed.start_of("day")
|
parsed_datetime = cast(pendulum.DateTime, parsed)
|
||||||
|
duration = parsed_datetime - parsed_datetime.start_of("day")
|
||||||
except pendulum.parsing.exceptions.ParserError as e:
|
except pendulum.parsing.exceptions.ParserError as e:
|
||||||
logger.trace(f"Invalid Pendulum time string format '{input_value}': {e}")
|
logger.trace(f"Invalid Pendulum time string format '{input_value}': {e}")
|
||||||
|
|
||||||
@@ -1516,6 +1550,7 @@ def compare_datetimes(
|
|||||||
DatetimesComparisonResult(equal=False, same_instant=True, time_diff=7200, timezone_diff=True, dst_diff=False, approximately_equal=True, ge=False, gt=False, le=True, lt=True)
|
DatetimesComparisonResult(equal=False, same_instant=True, time_diff=7200, timezone_diff=True, dst_diff=False, approximately_equal=True, ge=False, gt=False, le=True, lt=True)
|
||||||
"""
|
"""
|
||||||
# Normalize tolerance to seconds
|
# Normalize tolerance to seconds
|
||||||
|
tolerance_seconds: float
|
||||||
if tolerance is None:
|
if tolerance is None:
|
||||||
tolerance_seconds = 0
|
tolerance_seconds = 0
|
||||||
elif isinstance(tolerance, pendulum.Duration):
|
elif isinstance(tolerance, pendulum.Duration):
|
||||||
|
|||||||
+5
-5
@@ -14,7 +14,7 @@ from contextlib import contextmanager
|
|||||||
from fnmatch import fnmatch
|
from fnmatch import fnmatch
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Optional, Union
|
from typing import Callable, Generator, Optional, Union, cast
|
||||||
from unittest.mock import PropertyMock, patch
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -377,7 +377,7 @@ def config_eos_factory(
|
|||||||
# Check user data directory pathes (config_default_dirs[-1] == data_default_dir_user)
|
# Check user data directory pathes (config_default_dirs[-1] == data_default_dir_user)
|
||||||
assert config_eos.general.data_folder_path == data_folder_path
|
assert config_eos.general.data_folder_path == data_folder_path
|
||||||
assert config_eos.general.data_output_subpath == Path("output")
|
assert config_eos.general.data_output_subpath == Path("output")
|
||||||
assert config_eos.cache.subpath == "cache"
|
assert config_eos.cache.subpath == Path("cache")
|
||||||
assert config_eos.cache.path() == config_default_dirs[-1] / "data/cache"
|
assert config_eos.cache.path() == config_default_dirs[-1] / "data/cache"
|
||||||
assert config_eos.logging.file_path == config_default_dirs[-1] / "data/output/eos.log"
|
assert config_eos.logging.file_path == config_default_dirs[-1] / "data/output/eos.log"
|
||||||
|
|
||||||
@@ -446,7 +446,7 @@ def cleanup_eos_eosdash(
|
|||||||
pids: list[int] = []
|
pids: list[int] = []
|
||||||
for _ in range(int(server_timeout / 3)):
|
for _ in range(int(server_timeout / 3)):
|
||||||
for conn in psutil.net_connections(kind="inet"):
|
for conn in psutil.net_connections(kind="inet"):
|
||||||
if conn.laddr.port == port and conn.pid is not None:
|
if conn.laddr and conn.laddr.port == port and conn.pid is not None:
|
||||||
try:
|
try:
|
||||||
process = psutil.Process(conn.pid)
|
process = psutil.Process(conn.pid)
|
||||||
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
|
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
|
||||||
@@ -497,7 +497,7 @@ def cleanup_eos_eosdash(
|
|||||||
pids = []
|
pids = []
|
||||||
for _ in range(int(server_timeout / 3)):
|
for _ in range(int(server_timeout / 3)):
|
||||||
for conn in psutil.net_connections(kind="inet"):
|
for conn in psutil.net_connections(kind="inet"):
|
||||||
if conn.laddr.port in (eosdash_port, 8504, 8555) and conn.pid is not None:
|
if conn.laddr and conn.laddr.port in (eosdash_port, 8504, 8555) and conn.pid is not None:
|
||||||
try:
|
try:
|
||||||
process = psutil.Process(conn.pid)
|
process = psutil.Process(conn.pid)
|
||||||
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
|
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
|
||||||
@@ -766,5 +766,5 @@ def set_other_timezone():
|
|||||||
yield _set_timezone
|
yield _set_timezone
|
||||||
|
|
||||||
# Restore the original timezone
|
# Restore the original timezone
|
||||||
pendulum.set_local_timezone(original_timezone)
|
cast(Callable[[pendulum.Timezone | pendulum.FixedTimezone], None], pendulum.set_local_timezone)(original_timezone)
|
||||||
assert pendulum.local_timezone() == original_timezone
|
assert pendulum.local_timezone() == original_timezone
|
||||||
|
|||||||
@@ -170,8 +170,7 @@ async def prepare_optimization_real_parameters() -> Genetic0OptimizationParamete
|
|||||||
print(f"start_solution: {start_solution}")
|
print(f"start_solution: {start_solution}")
|
||||||
|
|
||||||
# Define parameters for the optimization problem
|
# Define parameters for the optimization problem
|
||||||
return Genetic0OptimizationParameters(
|
return Genetic0OptimizationParameters.model_validate({
|
||||||
**{
|
|
||||||
"ems": {
|
"ems": {
|
||||||
"price_per_wh_battery": 0e-05,
|
"price_per_wh_battery": 0e-05,
|
||||||
"feed_in_tariff_per_wh": 7e-05,
|
"feed_in_tariff_per_wh": 7e-05,
|
||||||
@@ -200,8 +199,7 @@ async def prepare_optimization_real_parameters() -> Genetic0OptimizationParamete
|
|||||||
},
|
},
|
||||||
"temperature_forecast": temperature_forecast,
|
"temperature_forecast": temperature_forecast,
|
||||||
"start_solution": start_solution,
|
"start_solution": start_solution,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
|
def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
|
||||||
@@ -366,8 +364,7 @@ def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
|
|||||||
start_solution = None
|
start_solution = None
|
||||||
|
|
||||||
# Define parameters for the optimization problem
|
# Define parameters for the optimization problem
|
||||||
return Genetic0OptimizationParameters(
|
return Genetic0OptimizationParameters.model_validate({
|
||||||
**{
|
|
||||||
"ems": {
|
"ems": {
|
||||||
"price_per_wh_battery": 0e-05,
|
"price_per_wh_battery": 0e-05,
|
||||||
"feed_in_tariff_per_wh": 7e-05,
|
"feed_in_tariff_per_wh": 7e-05,
|
||||||
@@ -396,8 +393,7 @@ def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
|
|||||||
},
|
},
|
||||||
"temperature_forecast": temperature_forecast,
|
"temperature_forecast": temperature_forecast,
|
||||||
"start_solution": start_solution,
|
"start_solution": start_solution,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_optimization(
|
def run_optimization(
|
||||||
|
|||||||
@@ -171,8 +171,7 @@ async def prepare_optimization_real_parameters() -> GeneticOptimizationParameter
|
|||||||
print(f"start_solution: {start_solution}")
|
print(f"start_solution: {start_solution}")
|
||||||
|
|
||||||
# Define parameters for the optimization problem
|
# Define parameters for the optimization problem
|
||||||
return GeneticOptimizationParameters(
|
return GeneticOptimizationParameters.model_validate({
|
||||||
**{
|
|
||||||
"ems": {
|
"ems": {
|
||||||
"price_per_wh_battery": 0e-05,
|
"price_per_wh_battery": 0e-05,
|
||||||
"feed_in_tariff_per_wh": 7e-05,
|
"feed_in_tariff_per_wh": 7e-05,
|
||||||
@@ -201,8 +200,7 @@ async def prepare_optimization_real_parameters() -> GeneticOptimizationParameter
|
|||||||
},
|
},
|
||||||
"temperature_forecast": temperature_forecast,
|
"temperature_forecast": temperature_forecast,
|
||||||
"start_solution": start_solution,
|
"start_solution": start_solution,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_optimization_parameters() -> GeneticOptimizationParameters:
|
def prepare_optimization_parameters() -> GeneticOptimizationParameters:
|
||||||
@@ -367,8 +365,7 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
|
|||||||
start_solution = None
|
start_solution = None
|
||||||
|
|
||||||
# Define parameters for the optimization problem
|
# Define parameters for the optimization problem
|
||||||
return GeneticOptimizationParameters(
|
return GeneticOptimizationParameters.model_validate({
|
||||||
**{
|
|
||||||
"ems": {
|
"ems": {
|
||||||
"price_per_wh_battery": 0e-05,
|
"price_per_wh_battery": 0e-05,
|
||||||
"feed_in_tariff_per_wh": 7e-05,
|
"feed_in_tariff_per_wh": 7e-05,
|
||||||
@@ -397,8 +394,7 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
|
|||||||
},
|
},
|
||||||
"temperature_forecast": temperature_forecast,
|
"temperature_forecast": temperature_forecast,
|
||||||
"start_solution": start_solution,
|
"start_solution": start_solution,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_optimization(
|
def run_optimization(
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class TestNodeREDAdapter:
|
|||||||
await adapter.update_data(force_enable=True)
|
await adapter.update_data(force_enable=True)
|
||||||
|
|
||||||
mock_get.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
|
assert adapter.update_datetime is not None
|
||||||
assert compare_datetimes(adapter.update_datetime, now).approximately_equal
|
assert compare_datetimes(adapter.update_datetime, now).approximately_equal
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+7
-2
@@ -17,7 +17,12 @@ from akkudoktoreos.core.cache import (
|
|||||||
cache_energy_management,
|
cache_energy_management,
|
||||||
cache_in_file,
|
cache_in_file,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import (
|
||||||
|
Duration,
|
||||||
|
compare_datetimes,
|
||||||
|
to_datetime,
|
||||||
|
to_duration,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------
|
# ---------------------------------
|
||||||
# In-Memory Caching Functionality
|
# In-Memory Caching Functionality
|
||||||
@@ -257,7 +262,7 @@ class TestCacheFileStore:
|
|||||||
assert ttl_duration is None
|
assert ttl_duration is None
|
||||||
|
|
||||||
# -- From now on we expect a until_datetime in one hour
|
# -- From now on we expect a until_datetime in one hour
|
||||||
ttl_duration_expected = to_duration("1 hour")
|
ttl_duration_expected: Duration | None = to_duration("1 hour")
|
||||||
|
|
||||||
# Test with with_ttl as timedelta
|
# Test with with_ttl as timedelta
|
||||||
until_datetime_expected = to_datetime().add(hours=1)
|
until_datetime_expected = to_datetime().add(hours=1)
|
||||||
|
|||||||
@@ -256,11 +256,11 @@ def test_config_common_settings_invalid(field_name, invalid_value, expected_erro
|
|||||||
"latitude": 40.7128,
|
"latitude": 40.7128,
|
||||||
"longitude": -74.0060,
|
"longitude": -74.0060,
|
||||||
}
|
}
|
||||||
assert GeneralSettings(**valid_data) is not None
|
assert GeneralSettings.model_validate(valid_data) is not None
|
||||||
valid_data[field_name] = invalid_value
|
valid_data[field_name] = invalid_value
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match=expected_error):
|
with pytest.raises(ValidationError, match=expected_error):
|
||||||
GeneralSettings(**valid_data)
|
GeneralSettings.model_validate(valid_data)
|
||||||
|
|
||||||
|
|
||||||
def test_config_common_settings_no_location():
|
def test_config_common_settings_no_location():
|
||||||
|
|||||||
+51
-51
@@ -55,11 +55,11 @@ def aware_dt(year, month, day, hour=0, minute=0, second=0, tz="Europe/Berlin"):
|
|||||||
|
|
||||||
def make_window(start_h, duration_h, **kwargs):
|
def make_window(start_h, duration_h, **kwargs):
|
||||||
"""Build a TimeWindow with a naive start_time at ``start_h:00``."""
|
"""Build a TimeWindow with a naive start_time at ``start_h:00``."""
|
||||||
return TimeWindow(
|
return TimeWindow.model_validate(dict(
|
||||||
start_time=f"{start_h:02d}:00:00",
|
start_time=f"{start_h:02d}:00:00",
|
||||||
duration=f"{duration_h} hours",
|
duration=f"{duration_h} hours",
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
))
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -73,10 +73,10 @@ class TestTimeWindowConstruction:
|
|||||||
|
|
||||||
def test_aware_start_time_stripped_to_naive(self):
|
def test_aware_start_time_stripped_to_naive(self):
|
||||||
"""An aware start_time is silently stripped to naive (to_time may add a tz)."""
|
"""An aware start_time is silently stripped to naive (to_time may add a tz)."""
|
||||||
w = TimeWindow(
|
w = TimeWindow.model_validate(dict(
|
||||||
start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("Europe/Berlin")),
|
start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("Europe/Berlin")),
|
||||||
duration="2 hours",
|
duration="2 hours",
|
||||||
)
|
))
|
||||||
assert w.start_time.tzinfo is None
|
assert w.start_time.tzinfo is None
|
||||||
assert w.start_time.hour == 8
|
assert w.start_time.hour == 8
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ class TestFitAndAvailable:
|
|||||||
|
|
||||||
class TestTimeWindowSequence:
|
class TestTimeWindowSequence:
|
||||||
def setup_method(self, method):
|
def setup_method(self, method):
|
||||||
self.seq = TimeWindowSequence(
|
self.seq = TimeWindowSequence[TimeWindow](
|
||||||
windows=[
|
windows=[
|
||||||
make_window(8, 2), # 08:00–10:00
|
make_window(8, 2), # 08:00–10:00
|
||||||
make_window(14, 3), # 14:00–17:00
|
make_window(14, 3), # 14:00–17:00
|
||||||
@@ -417,15 +417,15 @@ class TestTimeWindowSequence:
|
|||||||
assert result == pendulum.duration(hours=5)
|
assert result == pendulum.duration(hours=5)
|
||||||
|
|
||||||
def test_empty_sequence_contains_false(self):
|
def test_empty_sequence_contains_false(self):
|
||||||
seq = TimeWindowSequence()
|
seq = TimeWindowSequence[TimeWindow]()
|
||||||
assert not seq.contains(naive_dt(2024, 6, 15, 9, 0, 0))
|
assert not seq.contains(naive_dt(2024, 6, 15, 9, 0, 0))
|
||||||
|
|
||||||
def test_empty_sequence_earliest_none(self):
|
def test_empty_sequence_earliest_none(self):
|
||||||
seq = TimeWindowSequence()
|
seq = TimeWindowSequence[TimeWindow]()
|
||||||
assert seq.earliest_start_time(pendulum.duration(hours=1), naive_dt(2024, 6, 15)) is None
|
assert seq.earliest_start_time(pendulum.duration(hours=1), naive_dt(2024, 6, 15)) is None
|
||||||
|
|
||||||
def test_empty_sequence_available_none(self):
|
def test_empty_sequence_available_none(self):
|
||||||
seq = TimeWindowSequence()
|
seq = TimeWindowSequence[TimeWindow]()
|
||||||
assert seq.available_duration(naive_dt(2024, 6, 15)) is None
|
assert seq.available_duration(naive_dt(2024, 6, 15)) is None
|
||||||
|
|
||||||
def test_get_applicable_windows(self):
|
def test_get_applicable_windows(self):
|
||||||
@@ -445,7 +445,7 @@ class TestTimeWindowSequence:
|
|||||||
assert fits[0].start_time.hour == 14
|
assert fits[0].start_time.hour == 14
|
||||||
|
|
||||||
def test_sort_windows_by_start_time(self):
|
def test_sort_windows_by_start_time(self):
|
||||||
seq = TimeWindowSequence(
|
seq = TimeWindowSequence[TimeWindow](
|
||||||
windows=[make_window(14, 1), make_window(8, 1)]
|
windows=[make_window(14, 1), make_window(8, 1)]
|
||||||
)
|
)
|
||||||
ref = naive_dt(2024, 6, 15)
|
ref = naive_dt(2024, 6, 15)
|
||||||
@@ -454,7 +454,7 @@ class TestTimeWindowSequence:
|
|||||||
assert seq.windows[1].start_time.hour == 14
|
assert seq.windows[1].start_time.hour == 14
|
||||||
|
|
||||||
def test_add_and_remove_window(self):
|
def test_add_and_remove_window(self):
|
||||||
seq = TimeWindowSequence()
|
seq = TimeWindowSequence[TimeWindow]()
|
||||||
w = make_window(10, 1)
|
w = make_window(10, 1)
|
||||||
seq.add_window(w)
|
seq.add_window(w)
|
||||||
assert len(seq) == 1
|
assert len(seq) == 1
|
||||||
@@ -463,7 +463,7 @@ class TestTimeWindowSequence:
|
|||||||
assert len(seq) == 0
|
assert len(seq) == 0
|
||||||
|
|
||||||
def test_remove_from_empty_raises(self):
|
def test_remove_from_empty_raises(self):
|
||||||
seq = TimeWindowSequence()
|
seq = TimeWindowSequence[TimeWindow]()
|
||||||
with pytest.raises(IndexError):
|
with pytest.raises(IndexError):
|
||||||
seq.remove_window(0)
|
seq.remove_window(0)
|
||||||
|
|
||||||
@@ -487,20 +487,20 @@ class TestTimeWindowSequence:
|
|||||||
|
|
||||||
class TestValueTimeWindow:
|
class TestValueTimeWindow:
|
||||||
def test_value_stored(self):
|
def test_value_stored(self):
|
||||||
w = ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.288)
|
w = ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=0.288))
|
||||||
assert w.value == pytest.approx(0.288)
|
assert w.value == pytest.approx(0.288)
|
||||||
|
|
||||||
def test_value_default_none(self):
|
def test_value_default_none(self):
|
||||||
w = ValueTimeWindow(start_time="08:00:00", duration="2 hours")
|
w = ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours"))
|
||||||
assert w.value is None
|
assert w.value is None
|
||||||
|
|
||||||
def test_inherits_aware_start_time_stripped(self):
|
def test_inherits_aware_start_time_stripped(self):
|
||||||
"""ValueTimeWindow inherits the strip-to-naive behaviour from TimeWindow."""
|
"""ValueTimeWindow inherits the strip-to-naive behaviour from TimeWindow."""
|
||||||
w = ValueTimeWindow(
|
w = ValueTimeWindow.model_validate(dict(
|
||||||
start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("UTC")),
|
start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("UTC")),
|
||||||
duration="2 hours",
|
duration="2 hours",
|
||||||
value=0.1,
|
value=0.1,
|
||||||
)
|
))
|
||||||
assert w.start_time.tzinfo is None
|
assert w.start_time.tzinfo is None
|
||||||
assert w.start_time.hour == 8
|
assert w.start_time.hour == 8
|
||||||
|
|
||||||
@@ -509,8 +509,8 @@ class TestValueTimeWindowSequence:
|
|||||||
def setup_method(self, method):
|
def setup_method(self, method):
|
||||||
self.seq = ValueTimeWindowSequence(
|
self.seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.25),
|
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.25)),
|
||||||
ValueTimeWindow(start_time="18:00:00", duration="4 hours", value=0.35),
|
ValueTimeWindow.model_validate(dict(start_time="18:00:00", duration="4 hours", value=0.35)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -528,7 +528,7 @@ class TestValueTimeWindowSequence:
|
|||||||
|
|
||||||
def test_get_value_none_value_returns_zero(self):
|
def test_get_value_none_value_returns_zero(self):
|
||||||
seq = ValueTimeWindowSequence(
|
seq = ValueTimeWindowSequence(
|
||||||
windows=[ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=None)]
|
windows=[ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=None))]
|
||||||
)
|
)
|
||||||
assert seq.get_value_for_datetime(naive_dt(2024, 6, 15, 9, 0, 0)) == pytest.approx(0.0)
|
assert seq.get_value_for_datetime(naive_dt(2024, 6, 15, 9, 0, 0)) == pytest.approx(0.0)
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ class TestTimeWindowSequenceToArray:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def setup_method(self, method):
|
def setup_method(self, method):
|
||||||
self.seq = TimeWindowSequence(
|
self.seq = TimeWindowSequence[TimeWindow](
|
||||||
windows=[
|
windows=[
|
||||||
make_window(8, 2), # 08:00–10:00
|
make_window(8, 2), # 08:00–10:00
|
||||||
make_window(14, 3), # 14:00–17:00
|
make_window(14, 3), # 14:00–17:00
|
||||||
@@ -697,7 +697,7 @@ class TestTimeWindowSequenceToArray:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def test_empty_sequence_all_zeros(self):
|
def test_empty_sequence_all_zeros(self):
|
||||||
seq = TimeWindowSequence()
|
seq = TimeWindowSequence[TimeWindow]()
|
||||||
start = naive_dt(2024, 6, 15, 0)
|
start = naive_dt(2024, 6, 15, 0)
|
||||||
end = naive_dt(2024, 6, 15, 4)
|
end = naive_dt(2024, 6, 15, 4)
|
||||||
arr = seq.to_array(start, end, pendulum.duration(hours=1))
|
arr = seq.to_array(start, end, pendulum.duration(hours=1))
|
||||||
@@ -710,7 +710,7 @@ class TestTimeWindowSequenceToArray:
|
|||||||
|
|
||||||
def test_day_of_week_constraint_respected(self):
|
def test_day_of_week_constraint_respected(self):
|
||||||
# Monday-only window; 2024-06-17 is Monday, 2024-06-18 is Tuesday
|
# Monday-only window; 2024-06-17 is Monday, 2024-06-18 is Tuesday
|
||||||
seq = TimeWindowSequence(windows=[make_window(8, 2, day_of_week=0)])
|
seq = TimeWindowSequence[TimeWindow](windows=[make_window(8, 2, day_of_week=0)])
|
||||||
monday_start = naive_dt(2024, 6, 17, 7)
|
monday_start = naive_dt(2024, 6, 17, 7)
|
||||||
tuesday_start = naive_dt(2024, 6, 18, 7)
|
tuesday_start = naive_dt(2024, 6, 18, 7)
|
||||||
end_offset = pendulum.duration(hours=4)
|
end_offset = pendulum.duration(hours=4)
|
||||||
@@ -737,7 +737,7 @@ class TestTimeWindowSequenceToSeries:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def setup_method(self, method):
|
def setup_method(self, method):
|
||||||
self.seq = TimeWindowSequence(
|
self.seq = TimeWindowSequence[TimeWindow](
|
||||||
windows=[
|
windows=[
|
||||||
make_window(8, 2),
|
make_window(8, 2),
|
||||||
make_window(14, 3),
|
make_window(14, 3),
|
||||||
@@ -858,7 +858,7 @@ class TestTimeWindowSequenceToSeries:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_sequence_all_zeros(self):
|
def test_empty_sequence_all_zeros(self):
|
||||||
seq = TimeWindowSequence()
|
seq = TimeWindowSequence[TimeWindow]()
|
||||||
start = naive_dt(2024, 6, 15, 0)
|
start = naive_dt(2024, 6, 15, 0)
|
||||||
end = naive_dt(2024, 6, 15, 4)
|
end = naive_dt(2024, 6, 15, 4)
|
||||||
|
|
||||||
@@ -885,8 +885,8 @@ class TestValueTimeWindowSequenceToArray:
|
|||||||
def setup_method(self, method):
|
def setup_method(self, method):
|
||||||
self.seq = ValueTimeWindowSequence(
|
self.seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.25),
|
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.25)),
|
||||||
ValueTimeWindow(start_time="18:00:00", duration="4 hours", value=0.35),
|
ValueTimeWindow.model_validate(dict(start_time="18:00:00", duration="4 hours", value=0.35)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -939,8 +939,8 @@ class TestValueTimeWindowSequenceToArray:
|
|||||||
def test_dropna_false_none_value_emits_nan(self):
|
def test_dropna_false_none_value_emits_nan(self):
|
||||||
seq = ValueTimeWindowSequence(
|
seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
|
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
|
||||||
ValueTimeWindow(start_time="12:00:00", duration="2 hours", value=0.5),
|
ValueTimeWindow.model_validate(dict(start_time="12:00:00", duration="2 hours", value=0.5)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
start = naive_dt(2024, 6, 15, 8)
|
start = naive_dt(2024, 6, 15, 8)
|
||||||
@@ -956,8 +956,8 @@ class TestValueTimeWindowSequenceToArray:
|
|||||||
def test_dropna_true_none_value_step_omitted(self):
|
def test_dropna_true_none_value_step_omitted(self):
|
||||||
seq = ValueTimeWindowSequence(
|
seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
|
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
|
||||||
ValueTimeWindow(start_time="12:00:00", duration="2 hours", value=0.5),
|
ValueTimeWindow.model_validate(dict(start_time="12:00:00", duration="2 hours", value=0.5)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
start = naive_dt(2024, 6, 15, 8)
|
start = naive_dt(2024, 6, 15, 8)
|
||||||
@@ -1018,8 +1018,8 @@ class TestValueTimeWindowSequenceToArray:
|
|||||||
def test_overlapping_windows_first_wins(self):
|
def test_overlapping_windows_first_wins(self):
|
||||||
seq = ValueTimeWindowSequence(
|
seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.10),
|
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.10)),
|
||||||
ValueTimeWindow(start_time="09:00:00", duration="4 hours", value=0.99),
|
ValueTimeWindow.model_validate(dict(start_time="09:00:00", duration="4 hours", value=0.99)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
start = naive_dt(2024, 6, 15, 9)
|
start = naive_dt(2024, 6, 15, 9)
|
||||||
@@ -1040,16 +1040,16 @@ class TestValueTimeWindowSequenceToSeries:
|
|||||||
def setup_method(self, method):
|
def setup_method(self, method):
|
||||||
self.seq = ValueTimeWindowSequence(
|
self.seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="08:00:00",
|
start_time="08:00:00",
|
||||||
duration="4 hours",
|
duration="4 hours",
|
||||||
value=0.25,
|
value=0.25,
|
||||||
),
|
)),
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="18:00:00",
|
start_time="18:00:00",
|
||||||
duration="4 hours",
|
duration="4 hours",
|
||||||
value=0.35,
|
value=0.35,
|
||||||
),
|
)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1096,16 +1096,16 @@ class TestValueTimeWindowSequenceToSeries:
|
|||||||
def test_dropna_false_none_value_emits_nan(self):
|
def test_dropna_false_none_value_emits_nan(self):
|
||||||
seq = ValueTimeWindowSequence(
|
seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="08:00:00",
|
start_time="08:00:00",
|
||||||
duration="2 hours",
|
duration="2 hours",
|
||||||
value=None,
|
value=None,
|
||||||
),
|
)),
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="12:00:00",
|
start_time="12:00:00",
|
||||||
duration="2 hours",
|
duration="2 hours",
|
||||||
value=0.5,
|
value=0.5,
|
||||||
),
|
)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1134,16 +1134,16 @@ class TestValueTimeWindowSequenceToSeries:
|
|||||||
def test_dropna_true_none_value_omits_timestamp(self):
|
def test_dropna_true_none_value_omits_timestamp(self):
|
||||||
seq = ValueTimeWindowSequence(
|
seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="08:00:00",
|
start_time="08:00:00",
|
||||||
duration="2 hours",
|
duration="2 hours",
|
||||||
value=None,
|
value=None,
|
||||||
),
|
)),
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="12:00:00",
|
start_time="12:00:00",
|
||||||
duration="2 hours",
|
duration="2 hours",
|
||||||
value=0.5,
|
value=0.5,
|
||||||
),
|
)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1254,16 +1254,16 @@ class TestValueTimeWindowSequenceToSeries:
|
|||||||
def test_overlapping_windows_first_wins(self):
|
def test_overlapping_windows_first_wins(self):
|
||||||
seq = ValueTimeWindowSequence(
|
seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="08:00:00",
|
start_time="08:00:00",
|
||||||
duration="4 hours",
|
duration="4 hours",
|
||||||
value=0.10,
|
value=0.10,
|
||||||
),
|
)),
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="09:00:00",
|
start_time="09:00:00",
|
||||||
duration="4 hours",
|
duration="4 hours",
|
||||||
value=0.99,
|
value=0.99,
|
||||||
),
|
)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1452,7 +1452,7 @@ class TestAlignToIntervalTimezoneInvariance:
|
|||||||
def test_vtws_naive_floor_utc(self, set_other_timezone):
|
def test_vtws_naive_floor_utc(self, set_other_timezone):
|
||||||
set_other_timezone("UTC")
|
set_other_timezone("UTC")
|
||||||
seq = ValueTimeWindowSequence(windows=[
|
seq = ValueTimeWindowSequence(windows=[
|
||||||
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.25)
|
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=0.25))
|
||||||
])
|
])
|
||||||
start = naive_dt(2024, 6, 15, 8, 10)
|
start = naive_dt(2024, 6, 15, 8, 10)
|
||||||
end = naive_dt(2024, 6, 15, 10, 10)
|
end = naive_dt(2024, 6, 15, 10, 10)
|
||||||
@@ -1465,7 +1465,7 @@ class TestAlignToIntervalTimezoneInvariance:
|
|||||||
def test_vtws_naive_floor_non_utc(self, set_other_timezone):
|
def test_vtws_naive_floor_non_utc(self, set_other_timezone):
|
||||||
set_other_timezone()
|
set_other_timezone()
|
||||||
seq = ValueTimeWindowSequence(windows=[
|
seq = ValueTimeWindowSequence(windows=[
|
||||||
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.25)
|
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=0.25))
|
||||||
])
|
])
|
||||||
start = naive_dt(2024, 6, 15, 8, 10)
|
start = naive_dt(2024, 6, 15, 8, 10)
|
||||||
end = naive_dt(2024, 6, 15, 10, 10)
|
end = naive_dt(2024, 6, 15, 10, 10)
|
||||||
@@ -1480,11 +1480,11 @@ class TestAlignToIntervalTimezoneInvariance:
|
|||||||
|
|
||||||
seq = ValueTimeWindowSequence(
|
seq = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="08:00:00",
|
start_time="08:00:00",
|
||||||
duration="2 hours",
|
duration="2 hours",
|
||||||
value=0.25,
|
value=0.25,
|
||||||
)
|
))
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import List, Optional, Type
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from pendulum import UTC
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import get_database
|
from akkudoktoreos.core.coreabc import get_database
|
||||||
@@ -44,7 +45,7 @@ class EnergyRecord(DataRecord):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EnergySequence(DataSequence):
|
class EnergySequence(DataSequence[EnergyRecord]):
|
||||||
records: List[EnergyRecord] = Field(
|
records: List[EnergyRecord] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
json_schema_extra={"description": "List of energy records"},
|
json_schema_extra={"description": "List of energy records"},
|
||||||
@@ -58,7 +59,7 @@ class EnergySequence(DataSequence):
|
|||||||
return "energy_test"
|
return "energy_test"
|
||||||
|
|
||||||
|
|
||||||
class PriceSequence(DataSequence):
|
class PriceSequence(DataSequence[EnergyRecord]):
|
||||||
"""Price data — overrides tiers to keep 15-min resolution for 2 weeks."""
|
"""Price data — overrides tiers to keep 15-min resolution for 2 weeks."""
|
||||||
|
|
||||||
records: List[EnergyRecord] = Field(
|
records: List[EnergyRecord] = Field(
|
||||||
@@ -78,7 +79,7 @@ class PriceSequence(DataSequence):
|
|||||||
return [(to_duration("14 days"), to_duration("1 hour"))]
|
return [(to_duration("14 days"), to_duration("1 hour"))]
|
||||||
|
|
||||||
|
|
||||||
class EnergyProvider(DataProvider):
|
class EnergyProvider(DataProvider[EnergyRecord]):
|
||||||
records: List[EnergyRecord] = Field(
|
records: List[EnergyRecord] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
json_schema_extra={"description": "List of energy records"},
|
json_schema_extra={"description": "List of energy records"},
|
||||||
@@ -101,7 +102,7 @@ class EnergyProvider(DataProvider):
|
|||||||
return self.provider_id()
|
return self.provider_id()
|
||||||
|
|
||||||
|
|
||||||
class PriceProvider(DataProvider):
|
class PriceProvider(DataProvider[EnergyRecord]):
|
||||||
records: List[EnergyRecord] = Field(
|
records: List[EnergyRecord] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
json_schema_extra={"description": "List of price records"},
|
json_schema_extra={"description": "List of price records"},
|
||||||
@@ -181,7 +182,7 @@ def _reset_singletons() -> None:
|
|||||||
"""
|
"""
|
||||||
for cls in (EnergySequence, PriceSequence, EnergyProvider, PriceProvider, EnergyContainer):
|
for cls in (EnergySequence, PriceSequence, EnergyProvider, PriceProvider, EnergyContainer):
|
||||||
try:
|
try:
|
||||||
cls.reset_instance()
|
getattr(cls, "reset_instance")()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -691,7 +692,7 @@ class TestDataSequenceCompactIntegrity:
|
|||||||
# DatabaseTimestamp already imported at top of file
|
# DatabaseTimestamp already imported at top of file
|
||||||
db_max_epoch = int(DatabaseTimestamp.to_datetime(db_max_ts).timestamp())
|
db_max_epoch = int(DatabaseTimestamp.to_datetime(db_max_ts).timestamp())
|
||||||
two_weeks_cutoff_epoch = ((db_max_epoch - 14*24*3600) // 3600) * 3600
|
two_weeks_cutoff_epoch = ((db_max_epoch - 14*24*3600) // 3600) * 3600
|
||||||
two_weeks_cutoff_dt = DateTime.fromtimestamp(two_weeks_cutoff_epoch, tz="UTC")
|
two_weeks_cutoff_dt = DateTime.fromtimestamp(two_weeks_cutoff_epoch, tz=UTC)
|
||||||
|
|
||||||
old_records = [r for r in seq.records if r.date_time and r.date_time < two_weeks_cutoff_dt]
|
old_records = [r for r in seq.records if r.date_time and r.date_time < two_weeks_cutoff_dt]
|
||||||
|
|
||||||
@@ -986,7 +987,7 @@ class TestDataSequenceSparseGuard:
|
|||||||
margin_sec = (max_offset + 2 * interval_minutes + 1) * 60
|
margin_sec = (max_offset + 2 * interval_minutes + 1) * 60
|
||||||
raw_base_epoch = window_end_epoch - margin_sec
|
raw_base_epoch = window_end_epoch - margin_sec
|
||||||
base_epoch = (raw_base_epoch // interval_sec) * interval_sec
|
base_epoch = (raw_base_epoch // interval_sec) * interval_sec
|
||||||
base = DateTime.fromtimestamp(base_epoch, tz="UTC")
|
base = DateTime.fromtimestamp(base_epoch, tz=UTC)
|
||||||
|
|
||||||
dts = []
|
dts = []
|
||||||
for off in offsets_minutes:
|
for off in offsets_minutes:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class DerivedRecord(DataRecord):
|
|||||||
return ["dish_washer_emr", "solar_power", "temp"]
|
return ["dish_washer_emr", "solar_power", "temp"]
|
||||||
|
|
||||||
|
|
||||||
class DerivedDataProvider(DataProvider):
|
class DerivedDataProvider(DataProvider[DerivedRecord]):
|
||||||
"""Concrete DataProvider for testing."""
|
"""Concrete DataProvider for testing."""
|
||||||
|
|
||||||
records: List[DerivedRecord] = Field(
|
records: List[DerivedRecord] = Field(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class DerivedRecord(DataRecord):
|
|||||||
return ["dish_washer_emr", "solar_power", "temp"]
|
return ["dish_washer_emr", "solar_power", "temp"]
|
||||||
|
|
||||||
|
|
||||||
class DerivedSequence(DataSequence):
|
class DerivedSequence(DataSequence[DerivedRecord]):
|
||||||
# overload
|
# overload
|
||||||
records: List[DerivedRecord] = Field(
|
records: List[DerivedRecord] = Field(
|
||||||
default_factory=list, description="List of DerivedRecord records"
|
default_factory=list, description="List of DerivedRecord records"
|
||||||
@@ -65,7 +65,7 @@ class DerivedSequence(DataSequence):
|
|||||||
return "DerivedSequence"
|
return "DerivedSequence"
|
||||||
|
|
||||||
|
|
||||||
class DerivedSequence2(DataSequence):
|
class DerivedSequence2(DataSequence[DerivedRecord]):
|
||||||
# overload
|
# overload
|
||||||
records: List[DerivedRecord] = Field(
|
records: List[DerivedRecord] = Field(
|
||||||
default_factory=list, description="List of DerivedRecord records"
|
default_factory=list, description="List of DerivedRecord records"
|
||||||
@@ -79,7 +79,7 @@ class DerivedSequence2(DataSequence):
|
|||||||
return "DerivedSequence2"
|
return "DerivedSequence2"
|
||||||
|
|
||||||
|
|
||||||
class DerivedDataProvider(DataProvider):
|
class DerivedDataProvider(DataProvider[DerivedRecord]):
|
||||||
"""A concrete subclass of DataProvider for testing purposes."""
|
"""A concrete subclass of DataProvider for testing purposes."""
|
||||||
|
|
||||||
# overload
|
# overload
|
||||||
@@ -108,7 +108,7 @@ class DerivedDataProvider(DataProvider):
|
|||||||
DerivedDataProvider.provider_updated = True
|
DerivedDataProvider.provider_updated = True
|
||||||
|
|
||||||
|
|
||||||
class DerivedDataImportProvider(DataImportProvider):
|
class DerivedDataImportProvider(DataImportProvider[DerivedRecord]):
|
||||||
"""A concrete subclass of DataImportProvider for testing purposes."""
|
"""A concrete subclass of DataImportProvider for testing purposes."""
|
||||||
|
|
||||||
# overload
|
# overload
|
||||||
|
|||||||
@@ -296,13 +296,13 @@ class TestDataRecord:
|
|||||||
|
|
||||||
def test_init_configured_field_like_data_applies_before_model_init(self):
|
def test_init_configured_field_like_data_applies_before_model_init(self):
|
||||||
"""Test that keys listed in `_configured_data_keys` are moved to `configured_data` at init time."""
|
"""Test that keys listed in `_configured_data_keys` are moved to `configured_data` at init time."""
|
||||||
record = DerivedRecord(
|
record = DerivedRecord.model_validate(dict(
|
||||||
date_time="2024-01-03T00:00:00+00:00",
|
date_time="2024-01-03T00:00:00+00:00",
|
||||||
data_value=42.0,
|
data_value=42.0,
|
||||||
dish_washer_emr=111.1,
|
dish_washer_emr=111.1,
|
||||||
solar_power=222.2,
|
solar_power=222.2,
|
||||||
temp=333.3 # assume `temp` is also a valid configured key
|
temp=333.3 # assume `temp` is also a valid configured key
|
||||||
)
|
))
|
||||||
|
|
||||||
assert record.data_value == 42.0
|
assert record.data_value == 42.0
|
||||||
assert record.configured_data == {
|
assert record.configured_data == {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class DerivedRecord(DataRecord):
|
|||||||
return ["dish_washer_emr", "solar_power", "temp"]
|
return ["dish_washer_emr", "solar_power", "temp"]
|
||||||
|
|
||||||
|
|
||||||
class DerivedSequence(DataSequence):
|
class DerivedSequence(DataSequence[DerivedRecord]):
|
||||||
# overload
|
# overload
|
||||||
records: List[DerivedRecord] = Field(
|
records: List[DerivedRecord] = Field(
|
||||||
default_factory=list, description="List of DerivedRecord records"
|
default_factory=list, description="List of DerivedRecord records"
|
||||||
@@ -66,7 +66,7 @@ class DerivedSequence(DataSequence):
|
|||||||
return "DerivedSequence"
|
return "DerivedSequence"
|
||||||
|
|
||||||
|
|
||||||
class DerivedSequence2(DataSequence):
|
class DerivedSequence2(DataSequence[DerivedRecord]):
|
||||||
# overload
|
# overload
|
||||||
records: List[DerivedRecord] = Field(
|
records: List[DerivedRecord] = Field(
|
||||||
default_factory=list, description="List of DerivedRecord records"
|
default_factory=list, description="List of DerivedRecord records"
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class SampleDataRecord(DataRecord):
|
|||||||
pressure: float = Field(default=0.0)
|
pressure: float = Field(default=0.0)
|
||||||
|
|
||||||
|
|
||||||
class SampleDataSequence(DataSequence):
|
class SampleDataSequence(DataSequence[SampleDataRecord]):
|
||||||
"""DataSequence subclass with database support."""
|
"""DataSequence subclass with database support."""
|
||||||
records: list[SampleDataRecord] = Field(default_factory=list)
|
records: list[SampleDataRecord] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ class SampleDataSequence(DataSequence):
|
|||||||
return "SampleDataSequence"
|
return "SampleDataSequence"
|
||||||
|
|
||||||
|
|
||||||
class SampleDataProvider(DataProvider):
|
class SampleDataProvider(DataProvider[SampleDataRecord]):
|
||||||
"""DataProvider subclass with database support."""
|
"""DataProvider subclass with database support."""
|
||||||
records: list[SampleDataRecord] = Field(default_factory=list)
|
records: list[SampleDataRecord] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@ class TestDataSequenceDatabaseProtocol:
|
|||||||
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=5))
|
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=5))
|
||||||
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
|
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
|
||||||
assert len(records) == 3
|
assert len(records) == 3
|
||||||
assert all(base_time.add(hours=2) <= r.date_time < base_time.add(hours=5) for r in records)
|
assert all(r.date_time is not None and base_time.add(hours=2) <= r.date_time < base_time.add(hours=5) for r in records)
|
||||||
|
|
||||||
async def test_delete_records(self, async_database_instance):
|
async def test_delete_records(self, async_database_instance):
|
||||||
sequence = SampleDataSequence()
|
sequence = SampleDataSequence()
|
||||||
|
|||||||
@@ -680,7 +680,7 @@ class SampleDataRecord(DataRecord):
|
|||||||
pressure: float = Field(default=0.0)
|
pressure: float = Field(default=0.0)
|
||||||
|
|
||||||
|
|
||||||
class SampleDataSequence(DataSequence):
|
class SampleDataSequence(DataSequence[SampleDataRecord]):
|
||||||
"""DataSequence subclass with database support."""
|
"""DataSequence subclass with database support."""
|
||||||
records: list[SampleDataRecord] = Field(default_factory=list)
|
records: list[SampleDataRecord] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -692,7 +692,7 @@ class SampleDataSequence(DataSequence):
|
|||||||
return "SampleDataSequence"
|
return "SampleDataSequence"
|
||||||
|
|
||||||
|
|
||||||
class SampleDataProvider(DataProvider):
|
class SampleDataProvider(DataProvider[SampleDataRecord]):
|
||||||
"""DataProvider subclass with database support."""
|
"""DataProvider subclass with database support."""
|
||||||
records: list[SampleDataRecord] = Field(default_factory=list)
|
records: list[SampleDataRecord] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from typing import Any, AsyncIterator, Iterator, Literal, Optional, Type, cast
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from numpydantic import NDArray, Shape
|
from numpydantic import NDArray, Shape
|
||||||
|
from pendulum import UTC
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from akkudoktoreos.core.databaseabc import (
|
from akkudoktoreos.core.databaseabc import (
|
||||||
@@ -57,7 +58,7 @@ class SampleRecord(BaseModel):
|
|||||||
return self.value
|
return self.value
|
||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
|
|
||||||
def model_dump(self) -> dict:
|
def model_dump(self, **kwargs: Any) -> dict:
|
||||||
return {"date_time": self.date_time, "value": self.value}
|
return {"date_time": self.date_time, "value": self.value}
|
||||||
|
|
||||||
|
|
||||||
@@ -303,7 +304,7 @@ class SampleSequence(DatabaseRecordProtocolMixin[SampleRecord]):
|
|||||||
if end_datetime is not None:
|
if end_datetime is not None:
|
||||||
resampled = resampled.truncate(after=end_datetime)
|
resampled = resampled.truncate(after=end_datetime)
|
||||||
|
|
||||||
return resampled.values
|
return resampled.to_numpy()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -381,7 +382,7 @@ class TestDatabaseRecordProtocolMixin:
|
|||||||
self, seq, start_str, value_count, interval_seconds
|
self, seq, start_str, value_count, interval_seconds
|
||||||
):
|
):
|
||||||
start_dt = to_datetime(start_str, in_timezone="Europe/Berlin")
|
start_dt = to_datetime(start_str, in_timezone="Europe/Berlin")
|
||||||
assert start_dt.tz.name == "Europe/Berlin"
|
assert start_dt.timezone_name == "Europe/Berlin"
|
||||||
|
|
||||||
db_start = DatabaseTimestamp.from_datetime(start_dt)
|
db_start = DatabaseTimestamp.from_datetime(start_dt)
|
||||||
generated = list(seq.db_generate_timestamps(db_start, value_count))
|
generated = list(seq.db_generate_timestamps(db_start, value_count))
|
||||||
@@ -390,7 +391,7 @@ class TestDatabaseRecordProtocolMixin:
|
|||||||
|
|
||||||
for db_dt in generated:
|
for db_dt in generated:
|
||||||
dt = DatabaseTimestamp.to_datetime(db_dt)
|
dt = DatabaseTimestamp.to_datetime(db_dt)
|
||||||
assert dt.tz.name == "UTC"
|
assert dt.timezone_name == "UTC"
|
||||||
|
|
||||||
assert len(generated) == len(set(generated)), "Duplicate UTC datetimes found"
|
assert len(generated) == len(set(generated)), "Duplicate UTC datetimes found"
|
||||||
|
|
||||||
@@ -1047,7 +1048,9 @@ class TestCompactDataIntegrity:
|
|||||||
interval_sec = 15 * 60
|
interval_sec = 15 * 60
|
||||||
expected_window_start = DateTime.fromtimestamp(
|
expected_window_start = DateTime.fromtimestamp(
|
||||||
(int(base.timestamp()) // interval_sec) * interval_sec,
|
(int(base.timestamp()) // interval_sec) * interval_sec,
|
||||||
tz="UTC",
|
tz=UTC,
|
||||||
)
|
)
|
||||||
|
assert compacted[0].date_time is not None
|
||||||
|
assert compacted[-1].date_time is not None
|
||||||
assert compacted[0].date_time >= expected_window_start
|
assert compacted[0].date_time >= expected_window_start
|
||||||
assert compacted[-1].date_time < cutoff
|
assert compacted[-1].date_time < cutoff
|
||||||
|
|||||||
+17
-17
@@ -7,7 +7,7 @@ including edge cases, error handling, and timezone behavior.
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import babel
|
import babel
|
||||||
@@ -621,7 +621,7 @@ class TestToTime:
|
|||||||
def test_to_time_invalid_input_type(self):
|
def test_to_time_invalid_input_type(self):
|
||||||
"""Test to_time with invalid input type."""
|
"""Test to_time with invalid input type."""
|
||||||
with pytest.raises(ValueError, match="Unsupported type"):
|
with pytest.raises(ValueError, match="Unsupported type"):
|
||||||
to_time({"invalid": "input"})
|
to_time(cast(Any, {"invalid": "input"}))
|
||||||
|
|
||||||
def test_to_time_invalid_hour_integer(self):
|
def test_to_time_invalid_hour_integer(self):
|
||||||
"""Test to_time with invalid hour as integer."""
|
"""Test to_time with invalid hour as integer."""
|
||||||
@@ -657,7 +657,7 @@ class TestToTime:
|
|||||||
def test_to_time_invalid_timezone_type(self):
|
def test_to_time_invalid_timezone_type(self):
|
||||||
"""Test to_time with invalid timezone type."""
|
"""Test to_time with invalid timezone type."""
|
||||||
with pytest.raises(ValueError, match="Invalid timezone"):
|
with pytest.raises(ValueError, match="Invalid timezone"):
|
||||||
to_time("14:30", in_timezone=123)
|
to_time("14:30", in_timezone=cast(Any, 123))
|
||||||
|
|
||||||
def test_to_time_microseconds_precision(self):
|
def test_to_time_microseconds_precision(self):
|
||||||
"""Test to_time preserves microsecond precision."""
|
"""Test to_time preserves microsecond precision."""
|
||||||
@@ -727,7 +727,7 @@ class TestTimeUtilityIntegration:
|
|||||||
test_time: Time
|
test_time: Time
|
||||||
|
|
||||||
# Test with string input
|
# Test with string input
|
||||||
model = TestModel(test_time="14:30:45")
|
model = TestModel.model_validate(dict(test_time="14:30:45"))
|
||||||
assert isinstance(model.test_time, Time)
|
assert isinstance(model.test_time, Time)
|
||||||
assert model.test_time.hour == 14
|
assert model.test_time.hour == 14
|
||||||
|
|
||||||
@@ -748,8 +748,8 @@ class TestTimeUtilityIntegration:
|
|||||||
|
|
||||||
for case in test_cases:
|
for case in test_cases:
|
||||||
# Both should produce the same result
|
# Both should produce the same result
|
||||||
direct_result = to_time(case)
|
direct_result = to_time(cast(Any, case))
|
||||||
model_result = TestModel(test_time=case).test_time
|
model_result = TestModel.model_validate(dict(test_time=case)).test_time
|
||||||
|
|
||||||
assert direct_result.hour == model_result.hour
|
assert direct_result.hour == model_result.hour
|
||||||
assert direct_result.minute == model_result.minute
|
assert direct_result.minute == model_result.minute
|
||||||
@@ -770,12 +770,12 @@ class ScheduleModel(PydanticBaseModel):
|
|||||||
class TestPendulumTypes:
|
class TestPendulumTypes:
|
||||||
|
|
||||||
def test_valid_schedule_model(self):
|
def test_valid_schedule_model(self):
|
||||||
model = ScheduleModel(
|
model = ScheduleModel.model_validate(dict(
|
||||||
start_time="14:30:00",
|
start_time="14:30:00",
|
||||||
run_duration=to_duration("PT2H"),
|
run_duration=to_duration("PT2H"),
|
||||||
scheduled_at=to_datetime("2025-07-04T09:00:00+02:00"),
|
scheduled_at=to_datetime("2025-07-04T09:00:00+02:00"),
|
||||||
run_on=to_datetime("2025-07-04")
|
run_on=to_datetime("2025-07-04")
|
||||||
)
|
))
|
||||||
|
|
||||||
assert isinstance(model.start_time, pendulum.Time)
|
assert isinstance(model.start_time, pendulum.Time)
|
||||||
assert isinstance(model.run_duration, pendulum.Duration)
|
assert isinstance(model.run_duration, pendulum.Duration)
|
||||||
@@ -788,12 +788,12 @@ class TestPendulumTypes:
|
|||||||
assert model.run_on.to_date_string() == "2025-07-04"
|
assert model.run_on.to_date_string() == "2025-07-04"
|
||||||
|
|
||||||
def test_json_serialization(self):
|
def test_json_serialization(self):
|
||||||
model = ScheduleModel(
|
model = ScheduleModel.model_validate(dict(
|
||||||
start_time=pendulum.time(6, 15),
|
start_time=pendulum.time(6, 15),
|
||||||
run_duration=pendulum.duration(minutes=45),
|
run_duration=pendulum.duration(minutes=45),
|
||||||
scheduled_at=pendulum.datetime(2025, 7, 4, 6, 15, tz="Europe/Berlin"),
|
scheduled_at=pendulum.datetime(2025, 7, 4, 6, 15, tz="Europe/Berlin"),
|
||||||
run_on=pendulum.date(2025, 7, 4)
|
run_on=pendulum.date(2025, 7, 4)
|
||||||
)
|
))
|
||||||
|
|
||||||
json_data = model.model_dump(mode="json")
|
json_data = model.model_dump(mode="json")
|
||||||
assert "06:15:00" in json_data["start_time"]
|
assert "06:15:00" in json_data["start_time"]
|
||||||
@@ -809,30 +809,30 @@ class TestPendulumTypes:
|
|||||||
|
|
||||||
def test_invalid_start_time(self):
|
def test_invalid_start_time(self):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
ScheduleModel(
|
ScheduleModel.model_validate(dict(
|
||||||
start_time="invalid",
|
start_time="invalid",
|
||||||
run_duration="PT1H",
|
run_duration="PT1H",
|
||||||
scheduled_at="2025-07-04T09:00:00+02:00",
|
scheduled_at="2025-07-04T09:00:00+02:00",
|
||||||
run_on="2025-07-04"
|
run_on="2025-07-04"
|
||||||
)
|
))
|
||||||
|
|
||||||
def test_invalid_duration(self):
|
def test_invalid_duration(self):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
ScheduleModel(
|
ScheduleModel.model_validate(dict(
|
||||||
start_time="10:00:00",
|
start_time="10:00:00",
|
||||||
run_duration="2 hours", # invalid ISO 8601 duration
|
run_duration="2 hours", # invalid ISO 8601 duration
|
||||||
scheduled_at="2025-07-04T09:00:00+02:00",
|
scheduled_at="2025-07-04T09:00:00+02:00",
|
||||||
run_on="2025-07-04"
|
run_on="2025-07-04"
|
||||||
)
|
))
|
||||||
|
|
||||||
def test_type_coercion(self):
|
def test_type_coercion(self):
|
||||||
dt = pendulum.datetime(2025, 7, 4, 12, 0)
|
dt = pendulum.datetime(2025, 7, 4, 12, 0)
|
||||||
model = ScheduleModel(
|
model = ScheduleModel.model_validate(dict(
|
||||||
start_time=pendulum.time(12, 0),
|
start_time=pendulum.time(12, 0),
|
||||||
run_duration=pendulum.duration(hours=3),
|
run_duration=pendulum.duration(hours=3),
|
||||||
scheduled_at=dt,
|
scheduled_at=dt,
|
||||||
run_on=dt.date()
|
run_on=dt.date()
|
||||||
)
|
))
|
||||||
assert model.scheduled_at.hour == 12
|
assert model.scheduled_at.hour == 12
|
||||||
assert model.run_duration.total_minutes() == 180
|
assert model.run_duration.total_minutes() == 180
|
||||||
|
|
||||||
@@ -1424,7 +1424,7 @@ def test_hours_in_day(set_other_timezone, local_timezone, date, in_timezone, exp
|
|||||||
"""Test the `test_hours_in_day` function."""
|
"""Test the `test_hours_in_day` function."""
|
||||||
set_other_timezone(local_timezone)
|
set_other_timezone(local_timezone)
|
||||||
date_input = to_datetime(date, in_timezone=in_timezone)
|
date_input = to_datetime(date, in_timezone=in_timezone)
|
||||||
assert date_input.timezone.name == in_timezone
|
assert date_input.timezone_name == in_timezone
|
||||||
assert hours_in_day(date_input) == expected_hours
|
assert hours_in_day(date_input) == expected_hours
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -16,6 +17,36 @@ DIR_TEST_GENERATED = DIR_TESTDATA / "docs" / "_generated"
|
|||||||
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS")
|
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_documentation_requires_a_timezone_name(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.syspath_prepend(str(DIR_PROJECT_ROOT))
|
||||||
|
from scripts import generate_config_md
|
||||||
|
|
||||||
|
monkeypatch.setattr(generate_config_md, "to_datetime", lambda: SimpleNamespace(timezone_name=None))
|
||||||
|
output = tmp_path / "config.md"
|
||||||
|
with pytest.raises(RuntimeError, match="Documentation generation requires a timezone name"):
|
||||||
|
generate_config_md.write_to_file(output, "Configuration documentation")
|
||||||
|
assert not output.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_time_windows_keep_nested_documentation(monkeypatch):
|
||||||
|
from akkudoktoreos.config.configabc import TimeWindowSequence
|
||||||
|
|
||||||
|
monkeypatch.syspath_prepend(str(DIR_PROJECT_ROOT))
|
||||||
|
from scripts import generate_config_md
|
||||||
|
|
||||||
|
monkeypatch.setattr(generate_config_md, "documented_types", set())
|
||||||
|
monkeypatch.setattr(generate_config_md, "undocumented_types", {})
|
||||||
|
markdown = generate_config_md.generate_config_table_md(
|
||||||
|
TimeWindowSequence, ["time_windows"], "", toplevel=True, extra_config=True
|
||||||
|
)
|
||||||
|
assert "`list[akkudoktoreos.config.configabc.TimeWindow]`" in markdown
|
||||||
|
assert ":::{table} time_windows::windows::list" in markdown
|
||||||
|
assert "| start_time | `Time`" in markdown
|
||||||
|
assert "| duration | `Duration`" in markdown
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(GITHUB_ACTIONS == "true", reason="Skipped on GitHub Actions - TODO!")
|
@pytest.mark.skipif(GITHUB_ACTIONS == "true", reason="Skipped on GitHub Actions - TODO!")
|
||||||
def test_openapi_spec_current(config_eos, set_other_timezone):
|
def test_openapi_spec_current(config_eos, set_other_timezone):
|
||||||
"""Verify the openapi spec hasn´t changed."""
|
"""Verify the openapi spec hasn´t changed."""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from docutils.core import publish_parts
|
|||||||
from docutils.frontend import get_default_settings
|
from docutils.frontend import get_default_settings
|
||||||
from docutils.parsers.rst import Directive, Parser, directives
|
from docutils.parsers.rst import Directive, Parser, directives
|
||||||
from docutils.utils import Reporter, new_document
|
from docutils.utils import Reporter, new_document
|
||||||
|
from sphinx.config import Config as SphinxConfig
|
||||||
from sphinx.ext.napoleon import Config as NapoleonConfig
|
from sphinx.ext.napoleon import Config as NapoleonConfig
|
||||||
from sphinx.ext.napoleon.docstring import GoogleDocstring
|
from sphinx.ext.napoleon.docstring import GoogleDocstring
|
||||||
|
|
||||||
@@ -341,7 +342,7 @@ def test_all_docstrings_rst_compliant():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# convert like sphinx napoleon does
|
# convert like sphinx napoleon does
|
||||||
doc_converted = str(GoogleDocstring(doc, napoleon_config))
|
doc_converted = str(GoogleDocstring(doc, cast(SphinxConfig, napoleon_config)))
|
||||||
|
|
||||||
# Register directives that sphinx knows - just to avaid errors
|
# Register directives that sphinx knows - just to avaid errors
|
||||||
prepare_docutils_for_sphinx()
|
prepare_docutils_for_sphinx()
|
||||||
|
|||||||
+19
-19
@@ -36,7 +36,7 @@ class TestElecFeeFixedCommonSettings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settings = ElecFeeFixedCommonSettings(**settings_dict)
|
settings = ElecFeeFixedCommonSettings.model_validate(settings_dict)
|
||||||
assert settings is not None
|
assert settings is not None
|
||||||
assert settings.consumption_amt_kwh is not None
|
assert settings.consumption_amt_kwh is not None
|
||||||
assert settings.consumption_amt_kwh.windows is not None
|
assert settings.consumption_amt_kwh.windows is not None
|
||||||
@@ -52,7 +52,7 @@ class TestElecFeeFixedCommonSettings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settings = ElecFeeFixedCommonSettings(**settings_dict)
|
settings = ElecFeeFixedCommonSettings.model_validate(settings_dict)
|
||||||
assert settings is not None
|
assert settings is not None
|
||||||
assert settings.consumption_percent_amt is not None
|
assert settings.consumption_percent_amt is not None
|
||||||
assert len(settings.consumption_percent_amt.windows) == 1
|
assert len(settings.consumption_percent_amt.windows) == 1
|
||||||
@@ -68,7 +68,7 @@ class TestElecFeeFixedCommonSettings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settings = ElecFeeFixedCommonSettings(**settings_dict)
|
settings = ElecFeeFixedCommonSettings.model_validate(settings_dict)
|
||||||
assert settings is not None
|
assert settings is not None
|
||||||
assert settings.feedin_amt_kwh is not None
|
assert settings.feedin_amt_kwh is not None
|
||||||
assert len(settings.feedin_amt_kwh.windows) == 2
|
assert len(settings.feedin_amt_kwh.windows) == 2
|
||||||
@@ -83,7 +83,7 @@ class TestElecFeeFixedCommonSettings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settings = ElecFeeFixedCommonSettings(**settings_dict)
|
settings = ElecFeeFixedCommonSettings.model_validate(settings_dict)
|
||||||
assert settings is not None
|
assert settings is not None
|
||||||
assert settings.feedin_percent_amt is not None
|
assert settings.feedin_percent_amt is not None
|
||||||
assert len(settings.feedin_percent_amt.windows) == 1
|
assert len(settings.feedin_percent_amt.windows) == 1
|
||||||
@@ -111,24 +111,24 @@ def elecfeefixed_settings():
|
|||||||
"""
|
"""
|
||||||
consumption_amt_kwh = ValueTimeWindowSequence(
|
consumption_amt_kwh = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.288),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="8 hours", value=0.288)),
|
||||||
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.34),
|
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.34)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
consumption_percent_amt = ValueTimeWindowSequence(
|
consumption_percent_amt = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="24 hours", value=19.0),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=19.0)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
feedin_amt_kwh = ValueTimeWindowSequence(
|
feedin_amt_kwh = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.08),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="8 hours", value=0.08)),
|
||||||
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.10),
|
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.10)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
feedin_percent_amt = ValueTimeWindowSequence(
|
feedin_percent_amt = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=5.0)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -285,18 +285,18 @@ class TestElecFeeFixed:
|
|||||||
partial_settings = ElecFeeFixedCommonSettings(
|
partial_settings = ElecFeeFixedCommonSettings(
|
||||||
consumption_amt_kwh=ValueTimeWindowSequence(
|
consumption_amt_kwh=ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="24 hours", value=0.3),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=0.3)),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
consumption_percent_amt=ValueTimeWindowSequence(windows=[]),
|
consumption_percent_amt=ValueTimeWindowSequence(windows=[]),
|
||||||
feedin_amt_kwh=ValueTimeWindowSequence(
|
feedin_amt_kwh=ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="24 hours", value=0.1),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=0.1)),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
feedin_percent_amt=ValueTimeWindowSequence(
|
feedin_percent_amt=ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=5.0)),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -387,24 +387,24 @@ class TestElecFeeFixedIntegration:
|
|||||||
# Configure with realistic German electricity fees (2024)
|
# Configure with realistic German electricity fees (2024)
|
||||||
consumption_amt_kwh = ValueTimeWindowSequence(
|
consumption_amt_kwh = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.288),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="8 hours", value=0.288)),
|
||||||
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.34),
|
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.34)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
consumption_percent_amt = ValueTimeWindowSequence(
|
consumption_percent_amt = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="24 hours", value=19.0),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=19.0)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
feedin_amt_kwh = ValueTimeWindowSequence(
|
feedin_amt_kwh = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.08),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="8 hours", value=0.08)),
|
||||||
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.10),
|
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.10)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
feedin_percent_amt = ValueTimeWindowSequence(
|
feedin_percent_amt = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
|
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=5.0)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TestElecPriceFixedCommonSettings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settings = ElecPriceFixedCommonSettings(**settings_dict)
|
settings = ElecPriceFixedCommonSettings.model_validate(settings_dict)
|
||||||
assert settings is not None
|
assert settings is not None
|
||||||
assert settings.elecprice_marketprice_amt_kwh is not None
|
assert settings.elecprice_marketprice_amt_kwh is not None
|
||||||
assert settings.elecprice_marketprice_amt_kwh.windows is not None
|
assert settings.elecprice_marketprice_amt_kwh.windows is not None
|
||||||
@@ -71,16 +71,16 @@ def provider(config_eos):
|
|||||||
# Create time windows
|
# Create time windows
|
||||||
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
|
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="00:00",
|
start_time="00:00",
|
||||||
duration="8 hours",
|
duration="8 hours",
|
||||||
value=0.288
|
value=0.288
|
||||||
),
|
)),
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="08:00",
|
start_time="08:00",
|
||||||
duration="16 hours",
|
duration="16 hours",
|
||||||
value=0.34
|
value=0.34
|
||||||
)
|
))
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=elecprice_marketprice_amt_kwh)
|
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=elecprice_marketprice_amt_kwh)
|
||||||
@@ -238,16 +238,16 @@ class TestElecPriceFixedIntegration:
|
|||||||
# Configure with realistic German electricity prices (2024)
|
# Configure with realistic German electricity prices (2024)
|
||||||
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
|
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
|
||||||
windows=[
|
windows=[
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="00:00",
|
start_time="00:00",
|
||||||
duration="8 hours",
|
duration="8 hours",
|
||||||
value=0.288 # Night rate
|
value=0.288 # Night rate
|
||||||
),
|
)),
|
||||||
ValueTimeWindow(
|
ValueTimeWindow.model_validate(dict(
|
||||||
start_time="08:00",
|
start_time="08:00",
|
||||||
duration="16 hours",
|
duration="16 hours",
|
||||||
value=0.34 # Day rate
|
value=0.34 # Day rate
|
||||||
)
|
))
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from akkudoktoreos.core.emplan import (
|
|||||||
BaseInstruction,
|
BaseInstruction,
|
||||||
CommodityQuantity,
|
CommodityQuantity,
|
||||||
DDBCInstruction,
|
DDBCInstruction,
|
||||||
|
EnergyManagementInstruction,
|
||||||
EnergyManagementPlan,
|
EnergyManagementPlan,
|
||||||
FRBCInstruction,
|
FRBCInstruction,
|
||||||
OMBCInstruction,
|
OMBCInstruction,
|
||||||
@@ -30,6 +31,7 @@ class TestEnergyManagementPlan:
|
|||||||
# Helpers (only used inside the class)
|
# Helpers (only used inside the class)
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
def _make_instr(self, resource_id, execution_time, duration=None):
|
def _make_instr(self, resource_id, execution_time, duration=None):
|
||||||
|
instr: OMBCInstruction | PEBCInstruction
|
||||||
if duration is None:
|
if duration is None:
|
||||||
instr = OMBCInstruction(
|
instr = OMBCInstruction(
|
||||||
id=resource_id,
|
id=resource_id,
|
||||||
@@ -169,7 +171,7 @@ class TestEnergyManagementPlan:
|
|||||||
generated_at=fixed_now,
|
generated_at=fixed_now,
|
||||||
instructions=[]
|
instructions=[]
|
||||||
)
|
)
|
||||||
instrs = [
|
instrs: list[EnergyManagementInstruction] = [
|
||||||
DDBCInstruction(
|
DDBCInstruction(
|
||||||
id="actuatorA@123",
|
id="actuatorA@123",
|
||||||
execution_time=fixed_now,
|
execution_time=fixed_now,
|
||||||
|
|||||||
@@ -265,13 +265,13 @@ class TestAcChargingInSimulation:
|
|||||||
|
|
||||||
simulation = Genetic0Simulation()
|
simulation = Genetic0Simulation()
|
||||||
simulation.prepare(
|
simulation.prepare(
|
||||||
Genetic0EnergyManagementParameters(
|
Genetic0EnergyManagementParameters.model_validate(dict(
|
||||||
pv_prognose_wh=[0.0] * prediction_hours, # No PV
|
pv_prognose_wh=[0.0] * prediction_hours, # No PV
|
||||||
strompreis_euro_pro_wh=[0.0003] * prediction_hours, # ~30ct/kWh
|
strompreis_euro_pro_wh=[0.0003] * prediction_hours, # ~30ct/kWh
|
||||||
einspeiseverguetung_euro_pro_wh=0.00008,
|
einspeiseverguetung_euro_pro_wh=0.00008,
|
||||||
preis_euro_pro_wh_akku=0.0001,
|
preis_euro_pro_wh_akku=0.0001,
|
||||||
gesamtlast=[1000.0] * prediction_hours, # 1 kW constant load
|
gesamtlast=[1000.0] * prediction_hours, # 1 kW constant load
|
||||||
),
|
)),
|
||||||
optimization_hours=config_eos.optimization.genetic0.horizon_hours,
|
optimization_hours=config_eos.optimization.genetic0.horizon_hours,
|
||||||
prediction_hours=prediction_hours,
|
prediction_hours=prediction_hours,
|
||||||
inverter=inverter,
|
inverter=inverter,
|
||||||
|
|||||||
@@ -239,13 +239,13 @@ def genetic0_simulation(config_eos) -> Genetic0Simulation:
|
|||||||
# Initialize the energy management system with the respective parameters
|
# Initialize the energy management system with the respective parameters
|
||||||
genetic0_simulation = Genetic0Simulation()
|
genetic0_simulation = Genetic0Simulation()
|
||||||
genetic0_simulation.prepare(
|
genetic0_simulation.prepare(
|
||||||
Genetic0EnergyManagementParameters(
|
Genetic0EnergyManagementParameters.model_validate(dict(
|
||||||
pv_prognose_wh=pv_prognose_wh,
|
pv_prognose_wh=pv_prognose_wh,
|
||||||
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
||||||
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
||||||
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
|
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
|
||||||
gesamtlast=gesamtlast,
|
gesamtlast=gesamtlast,
|
||||||
),
|
)),
|
||||||
optimization_hours = config_eos.optimization.genetic0.horizon_hours,
|
optimization_hours = config_eos.optimization.genetic0.horizon_hours,
|
||||||
prediction_hours = config_eos.prediction.hours,
|
prediction_hours = config_eos.prediction.hours,
|
||||||
inverter=inverter,
|
inverter=inverter,
|
||||||
@@ -387,13 +387,13 @@ def genetic0_simulation_2(config_eos) -> Genetic0Simulation:
|
|||||||
# Initialize the energy management system with the respective parameters
|
# Initialize the energy management system with the respective parameters
|
||||||
simulation = Genetic0Simulation()
|
simulation = Genetic0Simulation()
|
||||||
simulation.prepare(
|
simulation.prepare(
|
||||||
Genetic0EnergyManagementParameters(
|
Genetic0EnergyManagementParameters.model_validate(dict(
|
||||||
pv_prognose_wh=pv_prognose_wh,
|
pv_prognose_wh=pv_prognose_wh,
|
||||||
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
||||||
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
||||||
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
|
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
|
||||||
gesamtlast=gesamtlast,
|
gesamtlast=gesamtlast,
|
||||||
),
|
)),
|
||||||
optimization_hours = config_eos.optimization.genetic0.horizon_hours,
|
optimization_hours = config_eos.optimization.genetic0.horizon_hours,
|
||||||
prediction_hours = config_eos.prediction.hours,
|
prediction_hours = config_eos.prediction.hours,
|
||||||
inverter=inverter,
|
inverter=inverter,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user