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:
|
||||
- uses: actions/checkout@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
|
||||
|
||||
# --- Static type checking ---
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v2.3.1
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- types-requests==2.33.0.20260712
|
||||
- pandas-stubs==3.0.5.260730
|
||||
- tokenize-rt==6.2.0
|
||||
- types-docutils==0.23.0.20260827
|
||||
- types-PyYaml==6.0.12.20260815
|
||||
name: mypy (locked development environment)
|
||||
entry: uv run --locked --exact --extra dev python -m mypy --config-file pyproject.toml
|
||||
language: unsupported
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
# --- Markdown linter ---
|
||||
- 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.
|
||||
|
||||
```bash
|
||||
uv sync --extra dev
|
||||
uv sync --locked --extra dev
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
uv run pre-commit install
|
||||
uv run pre-commit install --hook-type commit-msg --hook-type pre-push
|
||||
uv run --locked --extra dev pre-commit install
|
||||
uv run --locked --extra dev pre-commit install --hook-type commit-msg --hook-type pre-push
|
||||
```
|
||||
|
||||
Or run them manually:
|
||||
|
||||
```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
|
||||
|
||||
Use `pytest` to run tests locally:
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
|
||||
# Use uv for all program actions
|
||||
UV := uv
|
||||
PYTHON := $(UV) run python
|
||||
UV_PYTHON := python3
|
||||
PYTHON := $(UV) run --python $(UV_PYTHON) python
|
||||
PYTEST := $(UV) run pytest
|
||||
MYPY := $(UV) run mypy
|
||||
PRECOMMIT := $(UV) run pre-commit
|
||||
MYPY := $(UV) run --locked --exact --extra dev python -m mypy --config-file pyproject.toml
|
||||
PRECOMMIT := $(UV) run --locked --extra dev pre-commit
|
||||
COMMITIZEN := $(UV) run cz
|
||||
|
||||
# - 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
|
||||
all: help
|
||||
@@ -160,7 +162,7 @@ format:
|
||||
gitlint:
|
||||
$(COMMITIZEN) check --rev-range main..HEAD
|
||||
|
||||
# Target to format code.
|
||||
# Check the complete typing policy in the locked development environment.
|
||||
mypy:
|
||||
$(MYPY)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| homeassistant | `EOS_ADAPTER__HOMEASSISTANT` | `HomeAssistantAdapterCommonSettings` | `rw` | `required` | Home Assistant 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. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
@@ -103,8 +103,8 @@ There are two URLs that are used:
|
||||
|
||||
| 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. |
|
||||
| port | `int | None` | `rw` | `1880` | Node-RED server IP port number. Defaults to 1880. |
|
||||
| host | `Optional[str]` | `rw` | `127.0.0.1` | Node-RED server IP address. Defaults to 127.0.0.1. |
|
||||
| port | `Optional[int]` | `rw` | `1880` | Node-RED server IP port number. Defaults to 1880. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -134,21 +134,21 @@ There are two URLs that are used:
|
||||
|
||||
| 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
|
||||
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.
|
||||
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_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_import_emr_entity_ids | `list[str] | None` | `rw` | `None` | Entity ID(s) of import from 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 | `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. |
|
||||
| load_emr_entity_ids | `list[str] | None` | `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] |
|
||||
| solution_entity_ids | `list[str] | None` | `rw` | `None` | Entity IDs for optimization solution keys to be updated by EOS.
|
||||
| load_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of load 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 | `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.
|
||||
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 |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| 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 -->
|
||||
|
||||
|
||||
@@ -13,16 +13,16 @@ Attributes:
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| keep_duration_h | `EOS_DATABASE__KEEP_DURATION_H` | `int | None` | `rw` | `None` | Default maximum duration records shall be kept in database [hours, none].
|
||||
| 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` | `Optional[int]` | `rw` | `None` | Default maximum duration records shall be kept in database [hours, none].
|
||||
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. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| batteries | `EOS_DEVICES__BATTERIES` | `list[akkudoktoreos.devices.devices.BatteriesCommonSettings] | None` | `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 |
|
||||
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings] | None` | `rw` | `None` | List of home appliances |
|
||||
| inverters | `EOS_DEVICES__INVERTERS` | `list[akkudoktoreos.devices.devices.InverterCommonSettings] | None` | `rw` | `None` | List of inverters |
|
||||
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `int | None` | `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_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `int | None` | `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 |
|
||||
| measurement_keys | | `list[str] | None` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. |
|
||||
| batteries | `EOS_DEVICES__BATTERIES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of battery devices |
|
||||
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of electric vehicle devices |
|
||||
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `Optional[list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings]]` | `rw` | `None` | List of home appliances |
|
||||
| inverters | `EOS_DEVICES__INVERTERS` | `Optional[list[akkudoktoreos.devices.devices.InverterCommonSettings]]` | `rw` | `None` | List of inverters |
|
||||
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries that can be set |
|
||||
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of electric vehicles that can be set |
|
||||
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home_appliances that can be set |
|
||||
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters that can be set |
|
||||
| measurement_keys | | `Optional[list[str]]` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -207,12 +207,12 @@
|
||||
| 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). |
|
||||
| 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). |
|
||||
| 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_power_w | `float | None` | `rw` | `None` | Maximum power [W]. |
|
||||
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
|
||||
| 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 | `Optional[float]` | `rw` | `None` | Maximum power [W]. |
|
||||
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -290,10 +290,10 @@ the model serialisable without timezone state.
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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 | `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`. |
|
||||
| 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(). |
|
||||
:::
|
||||
<!-- 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]. |
|
||||
| device_id | `str` | `rw` | `required` | ID of device |
|
||||
| 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. |
|
||||
| time_windows | `akkudoktoreos.config.configabc.TimeWindowSequence | None` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
|
||||
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
|
||||
| time_windows | `Optional[akkudoktoreos.config.configabc.TimeWindowSequence]` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -452,20 +452,20 @@ as a cohesive unit for scheduling and availability checking.
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| 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]. |
|
||||
| device_id | `str` | `rw` | `required` | ID of device |
|
||||
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
|
||||
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [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 [%]. |
|
||||
| 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_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_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. |
|
||||
| min_charge_power_w | `float | None` | `rw` | `50` | Minimum charging power [W]. |
|
||||
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the battery stati that are measurements. |
|
||||
| min_charge_power_w | `Optional[float]` | `rw` | `50` | Minimum charging power [W]. |
|
||||
| min_soc_percentage | `int` | `rw` | `0` | Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| elecfeefixed | `EOS_ELECFEE__ELECFEEFIXED` | `ElecFeeFixedCommonSettings` | `rw` | `required` | Fixed electricity fees 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. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
@@ -91,8 +91,8 @@
|
||||
|
||||
| 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_json | `str | None` | `rw` | `None` | JSON string, dictionary of electricity fee forecast value lists. |
|
||||
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import elecfee data from. |
|
||||
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of electricity fee forecast value lists. |
|
||||
:::
|
||||
<!-- 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 |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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 | `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`. |
|
||||
| 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(). |
|
||||
| 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 -->
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price 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. |
|
||||
| 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. |
|
||||
| smard | `EOS_ELECPRICE__SMARD` | `ElecPriceSMARDCommonSettings` | `rw` | `required` | SMARD 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 |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| access_token | `str | None` | `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. |
|
||||
| access_token | `Optional[str]` | `rw` | `None` | Tibber API access token. |
|
||||
| home_id | `Optional[str]` | `rw` | `None` | Optional Tibber home id. If omitted, the first home with a subscription is used. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -196,8 +196,8 @@
|
||||
|
||||
| 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_json | `str | None` | `rw` | `None` | JSON string, dictionary of electricity price forecast value lists. |
|
||||
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import elecprice data from. |
|
||||
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of electricity price forecast value lists. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| 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. |
|
||||
| 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. |
|
||||
| smard | `EOS_FEEDINTARIFF__SMARD` | `FeedInTariffSMARDCommonSettings` | `rw` | `required` | SMARD feed in tariff provider settings. |
|
||||
:::
|
||||
@@ -123,8 +123,8 @@
|
||||
|
||||
| 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_json | `str | None` | `rw` | `None` | JSON string, dictionary of feed in tariff forecast value lists. |
|
||||
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import feed in tariff data from. |
|
||||
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of feed in tariff forecast value lists. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| config_file_path | | `pathlib.Path | None` | `ro` | `N/A` | Path to EOS configuration file. |
|
||||
| config_folder_path | | `pathlib.Path | None` | `ro` | `N/A` | Path to EOS configuration directory. |
|
||||
| config_file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration file. |
|
||||
| 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_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_output_path | | `pathlib.Path | None` | `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_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Computed data_output_path based on data_folder_path. |
|
||||
| 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. |
|
||||
| latitude | `EOS_GENERAL__LATITUDE` | `float | None` | `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 (°) |
|
||||
| timezone | | `str | None` | `ro` | `N/A` | Computed timezone based on latitude and longitude. |
|
||||
| version | `EOS_GENERAL__VERSION` | `str | None` | `rw` | `None` | Configuration file version. |
|
||||
| latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees between -90 and 90. North is positive (ISO 19115) (°) |
|
||||
| longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees within -180 to 180 (°) |
|
||||
| timezone | | `Optional[str]` | `ro` | `N/A` | Computed timezone based on latitude and longitude. |
|
||||
| version | `EOS_GENERAL__VERSION` | `Optional[str]` | `rw` | `None` | Configuration file version. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| loadakkudoktor | `EOS_LOAD__LOADAKKUDOKTOR` | `LoadAkkudoktorCommonSettings` | `rw` | `required` | LoadAkkudoktor 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. |
|
||||
| vrm | `EOS_LOAD__VRM` | `LoadVrmCommonSettings` | `rw` | `required` | Victron Remote Management (VRM) provider settings. |
|
||||
:::
|
||||
@@ -111,8 +111,8 @@
|
||||
|
||||
| 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_json | `str | None` | `rw` | `None` | JSON string, dictionary of load forecast value lists. |
|
||||
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import load data from. |
|
||||
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of load forecast value lists. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
|
||||
| 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 -->
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| api_level | `EOS_LOGGING__API_LEVEL` | `str | None` | `rw` | `None` | Logging level for API response. |
|
||||
| console_level | `EOS_LOGGING__CONSOLE_LEVEL` | `str | None` | `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_path | | `pathlib.Path | None` | `ro` | `N/A` | Computed log file path based on data output path. |
|
||||
| api_level | `EOS_LOGGING__API_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level for API response. |
|
||||
| console_level | `EOS_LOGGING__CONSOLE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level for logging to console. |
|
||||
| file_level | `EOS_LOGGING__FILE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level for logging to file. |
|
||||
| file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Computed log file path based on data output path. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
| 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_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]. |
|
||||
| historic_hours | `EOS_MEASUREMENT__HISTORIC_HOURS` | `int | None` | `rw` | `17520` | Number of hours into the past for measurement data |
|
||||
| grid_export_emr_keys | `EOS_MEASUREMENT__GRID_EXPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy export to grid [kWh]. |
|
||||
| grid_import_emr_keys | `EOS_MEASUREMENT__GRID_IMPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy import from grid [kWh]. |
|
||||
| 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. |
|
||||
| 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]. |
|
||||
| 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]. |
|
||||
| load_emr_keys | `EOS_MEASUREMENT__LOAD_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of a load [kWh]. |
|
||||
| pv_production_emr_keys | `EOS_MEASUREMENT__PV_PRODUCTION_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are PV production energy meter readings [kWh]. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -98,13 +98,13 @@
|
||||
|
||||
| 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_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). |
|
||||
| penalties | `dict[str, float | int | str]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
|
||||
| seed | `int | None` | `rw` | `None` | Random seed for reproducibility. None = random. |
|
||||
| penalties | `dict[str, Union[float, int, str]]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
|
||||
| seed | `Optional[int]` | `rw` | `None` | Random seed for reproducibility. None = random. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -163,13 +163,13 @@
|
||||
|
||||
| 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_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) |
|
||||
| penalties | `dict[str, float | int | str]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
|
||||
| seed | `int | None` | `rw` | `None` | Random seed for reproducibility. None = random. |
|
||||
| penalties | `dict[str, Union[float, int, str]]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
|
||||
| seed | `Optional[int]` | `rw` | `None` | Random seed for reproducibility. None = random. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
| 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 |
|
||||
| hours | `EOS_PREDICTION__HOURS` | `int | None` | `rw` | `48` | Number of hours into the future for predictions |
|
||||
| historic_hours | `EOS_PREDICTION__HISTORIC_HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the past for historical predictions data |
|
||||
| hours | `EOS_PREDICTION__HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the future for predictions |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| forecastsolar | `EOS_PVFORECAST__FORECASTSOLAR` | `PVForecastForecastSolarCommonSettings` | `rw` | `required` | ForecastSolar 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 |
|
||||
| planes | `EOS_PVFORECAST__PLANES` | `list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting] | None` | `rw` | `None` | Plane configuration. |
|
||||
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set |
|
||||
| planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. |
|
||||
| planes_azimuth | | `List[float]` | `ro` | `N/A` | 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_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_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. |
|
||||
| pvforecastimport | `EOS_PVFORECAST__PVFORECASTIMPORT` | `PVForecastImportCommonSettings` | `rw` | `required` | PV forecast import provider settings |
|
||||
| pvlib | `EOS_PVFORECAST__PVLIB` | `PVForecastPVLibCommonSettings` | `rw` | `required` | PVLib provider settings |
|
||||
@@ -206,6 +206,7 @@
|
||||
"providers": [
|
||||
"PVForecastAkkudoktor",
|
||||
"PVForecastForecastSolar",
|
||||
"PVForecastHomeAssistant",
|
||||
"PVForecastImport",
|
||||
"PVForecastPVLib",
|
||||
"PVForecastPVNode",
|
||||
@@ -276,47 +277,6 @@
|
||||
```
|
||||
<!-- 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
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
@@ -359,7 +319,7 @@
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| 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). |
|
||||
| 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 -->
|
||||
|
||||
@@ -416,8 +376,8 @@
|
||||
|
||||
| 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_json | `str | None` | `rw` | `None` | JSON string, dictionary of PV forecast value lists. |
|
||||
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import PV forecast data from. |
|
||||
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of PV forecast value lists. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -447,22 +407,22 @@
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| albedo | `float | None` | `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_paco | `int | None` | `rw` | `None` | AC power rating of the inverter [W]. |
|
||||
| loss | `float | None` | `rw` | `14.0` | Sum of PV system losses in percent |
|
||||
| module_model | `str | None` | `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. |
|
||||
| mountingplace | `str | None` | `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. |
|
||||
| optimalangles | `bool | None` | `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. |
|
||||
| pvtechchoice | `str | None` | `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. |
|
||||
| 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_tilt | `float | None` | `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. |
|
||||
| userhorizon | `List[float] | None` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
|
||||
| albedo | `Optional[float]` | `rw` | `0.2` | Proportion of the light hitting the ground that it reflects back. |
|
||||
| inverter_model | `Optional[str]` | `rw` | `None` | Model of the inverter of this plane. |
|
||||
| inverter_paco | `Optional[int]` | `rw` | `None` | AC power rating of the inverter [W]. |
|
||||
| loss | `Optional[float]` | `rw` | `14.0` | Sum of PV system losses in percent |
|
||||
| module_model | `Optional[str]` | `rw` | `None` | Model of the PV modules of this plane. |
|
||||
| modules_per_string | `Optional[int]` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
|
||||
| mountingplace | `Optional[str]` | `rw` | `building` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
|
||||
| optimal_surface_tilt | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
|
||||
| optimalangles | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
|
||||
| peakpower | `Optional[float]` | `rw` | `None` | Nominal power of PV system in kW. |
|
||||
| pvtechchoice | `Optional[str]` | `rw` | `crystSi` | PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. |
|
||||
| strings_per_inverter | `Optional[int]` | `rw` | `None` | Number of the strings of the inverter of this plane. |
|
||||
| surface_azimuth | `Optional[float]` | `rw` | `180.0` | Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). |
|
||||
| surface_tilt | `Optional[float]` | `rw` | `30.0` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
|
||||
| trackingtype | `Optional[int]` | `rw` | `None` | Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. |
|
||||
| userhorizon | `Optional[List[float]]` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -503,6 +463,47 @@
|
||||
```
|
||||
<!-- 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
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
@@ -512,7 +513,7 @@
|
||||
|
||||
| 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 -->
|
||||
|
||||
|
||||
@@ -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]. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `bool | None` | `rw` | `True` | EOS server to start EOSdash server. Defaults to True. |
|
||||
| verbose | `EOS_SERVER__VERBOSE` | `bool | None` | `rw` | `False` | Enable debug output |
|
||||
| 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` | `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` | `Optional[bool]` | `rw` | `True` | EOS server to start EOSdash server. Defaults to True. |
|
||||
| verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
| 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. |
|
||||
| weatherimport | `EOS_WEATHER__WEATHERIMPORT` | `WeatherImportCommonSettings` | `rw` | `required` | Weather import provider settings |
|
||||
:::
|
||||
@@ -64,8 +64,8 @@
|
||||
|
||||
| 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_json | `str | None` | `rw` | `None` | JSON string, dictionary of weather forecast value lists. |
|
||||
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import weather data from. |
|
||||
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of weather forecast value lists. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Akkudoktor-EOS
|
||||
|
||||
**Version**: `v0.3.0.dev2609011977897641`
|
||||
**Version**: `v0.3.0.dev2609101507291966`
|
||||
|
||||
<!-- 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.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||
},
|
||||
"version": "v0.3.0.dev2609011977897641"
|
||||
"version": "v0.3.0.dev2609101507291966"
|
||||
},
|
||||
"paths": {
|
||||
"/v1/admin/cache/clear": {
|
||||
|
||||
+16
-1
@@ -54,7 +54,7 @@ dev = [
|
||||
# - pre-commit-hooks
|
||||
# - isort
|
||||
# - 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
|
||||
# - commitizen - sync with requirements-dev.txt (if on pypi)
|
||||
#
|
||||
@@ -66,6 +66,8 @@ dev = [
|
||||
"tokenize-rt==6.2.0", # for mypy
|
||||
"types-docutils==0.23.0.20260827", # 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",
|
||||
"deprecated==1.3.1", # for commitizen
|
||||
|
||||
@@ -168,12 +170,19 @@ filterwarnings = [
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ["numpydantic.mypy", "pydantic.mypy"]
|
||||
mypy_path= "src"
|
||||
python_version = "3.13"
|
||||
platform = "linux"
|
||||
files = ["src", "tests"]
|
||||
exclude = "class_soc_calc\\.py$"
|
||||
check_untyped_defs = 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]]
|
||||
module = "akkudoktoreos.*"
|
||||
@@ -191,6 +200,12 @@ ignore_missing_imports = true
|
||||
module = "xprocess.*"
|
||||
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]
|
||||
# Only used as linter
|
||||
name = "cz_conventional_commits"
|
||||
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
import sys
|
||||
import textwrap
|
||||
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 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.utils.datetimeutil import to_datetime
|
||||
|
||||
documented_types: set[PydanticBaseModel] = set()
|
||||
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
|
||||
documented_types: set[type[PydanticBaseModel]] = set()
|
||||
undocumented_types: dict[type[PydanticBaseModel], tuple[str, list[str]]] = 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]]]:
|
||||
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)
|
||||
if origin is Union:
|
||||
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:
|
||||
default_value = ""
|
||||
if regular_field:
|
||||
if regular_field and isinstance(field_info, FieldInfo):
|
||||
if (val := field_info.default) is not PydanticUndefined:
|
||||
default_value = val
|
||||
else:
|
||||
@@ -177,8 +180,13 @@ def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_f
|
||||
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")
|
||||
# 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"):
|
||||
type_name = field_type.__name__
|
||||
return type_name
|
||||
@@ -229,17 +237,14 @@ def generate_config_table_md(
|
||||
table += f"| ---- {env_header_underline}| ---- | --------- | ------- | ----------- |\n"
|
||||
|
||||
|
||||
fields = {}
|
||||
for field_name, field_info in config.model_fields.items():
|
||||
fields[field_name] = field_info
|
||||
for field_name, field_info in config.model_computed_fields.items():
|
||||
fields[field_name] = field_info
|
||||
fields: dict[str, FieldInfo | ComputedFieldInfo] = dict(config.model_fields)
|
||||
fields.update(config.model_computed_fields)
|
||||
for field_name in sorted(fields.keys()):
|
||||
field_info = fields[field_name]
|
||||
regular_field = isinstance(field_info, FieldInfo)
|
||||
|
||||
config_name = field_name if extra_config else field_name.upper()
|
||||
field_type = field_info.annotation if regular_field else field_info.return_type
|
||||
field_type = (field_info.annotation if isinstance(field_info, FieldInfo) else field_info.return_type)
|
||||
default_value = get_default_value(field_info, regular_field)
|
||||
description = config.field_description(field_name)
|
||||
deprecated = config.field_deprecated(field_name)
|
||||
@@ -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
|
||||
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)
|
||||
# Also replace UTC, as GitHub CI always is on UTC
|
||||
config_md = re.sub(re.escape("UTC"), "Europe/Berlin", config_md, flags=re.IGNORECASE)
|
||||
|
||||
@@ -5,10 +5,13 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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
|
||||
import generate_openapi
|
||||
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."""
|
||||
|
||||
providers: list[
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from abc import abstractmethod
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Generic, Optional, TypeVar
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import (
|
||||
@@ -102,18 +102,21 @@ class AdapterProvider(SingletonMixin, ConfigMixin, MeasurementMixin, StartMixin,
|
||||
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.
|
||||
|
||||
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"}
|
||||
)
|
||||
|
||||
@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
|
||||
for item in value:
|
||||
if not isinstance(item, AdapterProvider):
|
||||
@@ -149,7 +152,7 @@ class AdapterContainer(SingletonMixin, ConfigMixin, PydanticBaseModel):
|
||||
return
|
||||
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.
|
||||
|
||||
This method searches through the list of all available providers and
|
||||
|
||||
@@ -145,7 +145,13 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
|
||||
"""Entity IDs available at Home Assistant."""
|
||||
try:
|
||||
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:
|
||||
return []
|
||||
return result
|
||||
@@ -156,7 +162,13 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
|
||||
"""Entity IDs for optimization solution available at EOS."""
|
||||
try:
|
||||
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:
|
||||
return []
|
||||
return result
|
||||
@@ -167,9 +179,13 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
|
||||
"""Entity IDs for energy management instructions available at EOS."""
|
||||
try:
|
||||
adapter_eos = get_adapter()
|
||||
result = adapter_eos.provider_by_id(
|
||||
"HomeAssistant"
|
||||
).get_eos_device_instruction_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_device_instruction_entity_ids()
|
||||
except Exception:
|
||||
return []
|
||||
return result
|
||||
|
||||
@@ -14,7 +14,7 @@ import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Optional, Type, Union
|
||||
from typing import Any, Callable, ClassVar, Optional, Type, Union
|
||||
|
||||
import pydantic_settings
|
||||
from loguru import logger
|
||||
@@ -122,6 +122,10 @@ def default_data_folder_path() -> Path:
|
||||
class GeneralSettings(SettingsBaseModel):
|
||||
"""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(
|
||||
default=ConfigSaveMode.AUTOMATIC,
|
||||
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(
|
||||
default="output",
|
||||
validate_default=True,
|
||||
json_schema_extra={"description": "Sub-path for the EOS output data folder."},
|
||||
)
|
||||
|
||||
@@ -402,14 +409,15 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
# Pydantic Settings accepts zero-argument callables as well as source objects.
|
||||
def settings_customise_sources( # type: ignore[override]
|
||||
cls,
|
||||
settings_cls: Type[pydantic_settings.BaseSettings],
|
||||
init_settings: pydantic_settings.PydanticBaseSettingsSource,
|
||||
env_settings: pydantic_settings.PydanticBaseSettingsSource,
|
||||
dotenv_settings: pydantic_settings.PydanticBaseSettingsSource,
|
||||
file_secret_settings: pydantic_settings.PydanticBaseSettingsSource,
|
||||
) -> tuple[pydantic_settings.PydanticBaseSettingsSource, ...]:
|
||||
) -> tuple[pydantic_settings.PydanticBaseSettingsSource | Callable[[], dict[str, Any]], ...]:
|
||||
"""Customizes the order and handling of settings sources for a pydantic_settings.BaseSettings subclass.
|
||||
|
||||
This method determines the sources for application configuration settings, including
|
||||
@@ -790,7 +798,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
required by ``self._setup()``.
|
||||
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():
|
||||
error_msg = f"Configuration backup `{backup_id}` not found."
|
||||
logger.error(error_msg)
|
||||
@@ -823,7 +835,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
"""
|
||||
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
|
||||
stem = base_path.stem
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import calendar
|
||||
import os
|
||||
import sys
|
||||
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 pandas as pd
|
||||
@@ -409,19 +409,23 @@ class TimeWindow(SettingsBaseModel):
|
||||
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.
|
||||
|
||||
Manages multiple TimeWindow objects and provides methods to work with them
|
||||
as a cohesive unit for scheduling and availability checking.
|
||||
"""
|
||||
|
||||
windows: list[TimeWindow] = Field(
|
||||
windows: list[TimeWindowT] = Field(
|
||||
default_factory=list,
|
||||
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."""
|
||||
return iter(self.windows)
|
||||
|
||||
@@ -429,7 +433,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
||||
"""Return the number of time windows in the sequence."""
|
||||
return len(self.windows)
|
||||
|
||||
def __getitem__(self, index: int) -> TimeWindow:
|
||||
def __getitem__(self, index: int) -> TimeWindowT:
|
||||
"""Allow indexing into the time windows."""
|
||||
return self.windows[index]
|
||||
|
||||
@@ -536,7 +540,9 @@ class TimeWindowSequence(SettingsBaseModel):
|
||||
total += d
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -556,7 +562,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
||||
|
||||
def find_windows_for_duration(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> list[TimeWindow]:
|
||||
) -> list[TimeWindowT]:
|
||||
"""Find all windows that can accommodate the given duration.
|
||||
|
||||
Args:
|
||||
@@ -575,7 +581,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
||||
|
||||
def get_all_possible_start_times(
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -739,7 +745,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -747,7 +753,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -781,7 +787,7 @@ class TimeWindowSequence(SettingsBaseModel):
|
||||
if reference_date is None:
|
||||
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)
|
||||
if start_time is None:
|
||||
return (1, reference_date)
|
||||
@@ -806,7 +812,7 @@ class ValueTimeWindow(TimeWindow):
|
||||
)
|
||||
|
||||
|
||||
class ValueTimeWindowSequence(TimeWindowSequence):
|
||||
class ValueTimeWindowSequence(TimeWindowSequence[ValueTimeWindow]):
|
||||
"""Sequence of value time windows.
|
||||
|
||||
This model specializes `TimeWindowSequence` to ensure that all
|
||||
|
||||
@@ -25,6 +25,7 @@ from typing import (
|
||||
Optional,
|
||||
ParamSpec,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
import cachebox
|
||||
@@ -46,7 +47,8 @@ from akkudoktoreos.utils.datetimeutil import (
|
||||
# ---------------------------------
|
||||
|
||||
# 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:
|
||||
@@ -195,7 +197,9 @@ class CacheEnergyManagementStore(SingletonMixin):
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
callable (Callable): The function or method to be decorated.
|
||||
func (Callable): The function or method to be decorated.
|
||||
|
||||
Returns:
|
||||
Callable: The wrapped function with caching functionality.
|
||||
@@ -218,24 +222,22 @@ def cache_energy_management(callable: TCallable) -> TCallable:
|
||||
|
||||
"""
|
||||
|
||||
@cachebox.cached(
|
||||
cache=CacheEnergyManagementStore().cache, callback=cache_energy_management_store_callback
|
||||
)
|
||||
@functools.wraps(callable)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
result = callable(*args, **kwargs)
|
||||
return result
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> RetType:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
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
|
||||
# ---------------------------------
|
||||
|
||||
Param = ParamSpec("Param")
|
||||
RetType = TypeVar("RetType")
|
||||
|
||||
|
||||
def cache_clear(clear_all: Optional[bool] = None) -> None:
|
||||
"""Cleanup expired cache files."""
|
||||
@@ -742,6 +744,9 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
||||
if clear_all:
|
||||
clear_file = True
|
||||
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
|
||||
|
||||
if clear_file:
|
||||
@@ -782,9 +787,11 @@ class CacheFileStore(ConfigMixin, SingletonMixin):
|
||||
with self._store_lock:
|
||||
store_current = {}
|
||||
for key, record in self._store.items():
|
||||
ttl_duration = record.ttl_duration
|
||||
if ttl_duration:
|
||||
ttl_duration = ttl_duration.total_seconds()
|
||||
ttl_duration = (
|
||||
record.ttl_duration.total_seconds()
|
||||
if record.ttl_duration
|
||||
else record.ttl_duration
|
||||
)
|
||||
store_current[key] = {
|
||||
# Convert file-like objects to file paths for serialization
|
||||
"cache_file": self._get_file_path(record.cache_file),
|
||||
|
||||
@@ -14,8 +14,10 @@ from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
class CacheCommonSettings(SettingsBaseModel):
|
||||
"""Cache Configuration."""
|
||||
|
||||
# Retain the raw serialized default for exclude_defaults compatibility.
|
||||
subpath: Optional[Path] = Field(
|
||||
default="cache",
|
||||
validate_default=True,
|
||||
json_schema_extra={"description": "Sub-path for the EOS cache data directory."},
|
||||
)
|
||||
|
||||
|
||||
@@ -20,11 +20,14 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Generic,
|
||||
Iterator,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
overload,
|
||||
)
|
||||
@@ -289,7 +292,8 @@ class DataRecord(DataABC, MutableMapping):
|
||||
except AttributeError:
|
||||
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.
|
||||
|
||||
Returns:
|
||||
@@ -441,7 +445,10 @@ class DataRecord(DataABC, MutableMapping):
|
||||
# ==================== 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.
|
||||
|
||||
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.
|
||||
records: list[DataRecord] = Field(
|
||||
records: list[DataRecordT] = Field(
|
||||
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}"
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -629,7 +636,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
return self.record_class().record_keys_writable()
|
||||
|
||||
@classmethod
|
||||
def record_class(cls) -> Type:
|
||||
def record_class(cls) -> Type[DataRecordT]:
|
||||
"""Get the class of the data record handled by this data sequence.
|
||||
|
||||
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"]
|
||||
# Get the list element type from the 'type_' attribute
|
||||
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):
|
||||
raise ValueError(
|
||||
f"Data record must be an instance of DataRecord: '{list_element_type}'."
|
||||
@@ -736,17 +745,18 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
|
||||
# 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).
|
||||
|
||||
Returns:
|
||||
Iterator[DataRecord]: An iterator for the records.
|
||||
Iterator[DataRecordT]: An iterator for the records.
|
||||
"""
|
||||
return iter(self.records)
|
||||
|
||||
async def get_by_datetime(
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -770,7 +780,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
|
||||
async def get_nearest_by_datetime(
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Internal implementation of `insert_by_datetime`. Callers must
|
||||
@@ -822,8 +832,10 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
"""
|
||||
self._validate_record(record)
|
||||
|
||||
# Ensure datetime objects are normalized
|
||||
record_date_time_timestamp = DatabaseTimestamp.from_datetime(record.date_time)
|
||||
# _validate_record normalizes the timestamp, including a missing value.
|
||||
record_date_time_timestamp = DatabaseTimestamp.from_datetime(
|
||||
self._db_require_date_time(record)
|
||||
)
|
||||
|
||||
avail_record = await self.db_get_record(record_date_time_timestamp)
|
||||
if avail_record:
|
||||
@@ -914,7 +926,9 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
avail_record = await self.db_get_record(db_target)
|
||||
if avail_record is None:
|
||||
# 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)
|
||||
else:
|
||||
# Update existing record's specified key
|
||||
@@ -949,7 +963,9 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
avail_record = await self.db_get_record(db_target)
|
||||
if avail_record is None:
|
||||
# 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)
|
||||
else:
|
||||
# Update existing record's specified key
|
||||
@@ -958,7 +974,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
|
||||
# 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.
|
||||
|
||||
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,
|
||||
end_datetime: Optional[DateTime] = None,
|
||||
dropna: bool = True,
|
||||
) -> Dict[DateTime, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
"""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,
|
||||
@@ -1130,7 +1146,8 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
end_timestamp is None or record_date_time_timestamp < end_timestamp
|
||||
):
|
||||
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]
|
||||
|
||||
return dates, values
|
||||
@@ -1310,7 +1327,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
query_start = DatabaseTimestamp.to_datetime(query_start_timestamp)
|
||||
if end_datetime is not None:
|
||||
# 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)
|
||||
if query_end_timestamp is None:
|
||||
# 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"
|
||||
)
|
||||
else:
|
||||
resample_origin = resample_start
|
||||
resample_origin = pd.Timestamp(resample_start)
|
||||
else:
|
||||
# Preserve original behaviour: buckets start at the resample start.
|
||||
resample_origin = resample_start
|
||||
if resample_origin is None:
|
||||
# We have no resample origin - take start of day as default
|
||||
resample_origin = "start_day"
|
||||
resample_origin = (
|
||||
pd.Timestamp(resample_start) if resample_start is not None else "start_day"
|
||||
)
|
||||
|
||||
# Check for numeric values
|
||||
numeric_series = pd.to_numeric(series, errors="coerce") # ensures float64, not object dtype
|
||||
@@ -1746,7 +1762,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
# ==================== 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.
|
||||
|
||||
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
|
||||
if not has_datetime_index:
|
||||
# Create values datetime list
|
||||
if start_datetime is None:
|
||||
raise ValueError("Timezone-aware datetime required")
|
||||
start_timestamp = DatabaseTimestamp.from_datetime(start_datetime)
|
||||
value_db_datetimes = list(
|
||||
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
|
||||
|
||||
# Try pandas dataframe with orient="split"
|
||||
import_data: PydanticDateTimeDataFrame | PydanticDateTimeData | dict[str, Any]
|
||||
try:
|
||||
import_data = PydanticDateTimeDataFrame.model_validate_json(json_str)
|
||||
await self._import_from_dataframe(import_data.to_dataframe())
|
||||
@@ -2123,7 +2142,7 @@ class DataImportMixin(StartMixin):
|
||||
|
||||
# Use simple dict format
|
||||
try:
|
||||
import_data = json.loads(json_str)
|
||||
import_data = cast(dict[str, Any], json.loads(json_str))
|
||||
await self._import_from_dict(
|
||||
import_data, key_prefix=key_prefix, start_datetime=start_datetime, interval=interval
|
||||
)
|
||||
@@ -2332,7 +2351,7 @@ class DataImportMixin(StartMixin):
|
||||
# ==================== DataImportProvider ====================
|
||||
|
||||
|
||||
class DataImportProvider(DataImportMixin, DataProvider):
|
||||
class DataImportProvider(DataImportMixin, DataProvider[DataRecordT], Generic[DataRecordT]):
|
||||
"""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.
|
||||
@@ -2350,7 +2369,10 @@ class DataImportProvider(DataImportMixin, DataProvider):
|
||||
# ==================== DataContainer ====================
|
||||
|
||||
|
||||
class DataContainer(SingletonMixin, DataABC):
|
||||
DataProviderT = TypeVar("DataProviderT", bound=DataProvider)
|
||||
|
||||
|
||||
class DataContainer(SingletonMixin, DataABC, Generic[DataProviderT]):
|
||||
"""A container for managing multiple DataProvider instances.
|
||||
|
||||
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.
|
||||
providers: list[DataProvider] = Field(
|
||||
providers: list[DataProviderT] = Field(
|
||||
default_factory=list, json_schema_extra={"description": "List of data providers"}
|
||||
)
|
||||
|
||||
@@ -2381,7 +2403,7 @@ class DataContainer(SingletonMixin, DataABC):
|
||||
return lock
|
||||
|
||||
@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
|
||||
for item in value:
|
||||
if not isinstance(item, DataProvider):
|
||||
@@ -2422,7 +2444,8 @@ class DataContainer(SingletonMixin, DataABC):
|
||||
return
|
||||
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.
|
||||
|
||||
Returns:
|
||||
@@ -2894,7 +2917,7 @@ class DataContainer(SingletonMixin, DataABC):
|
||||
if key_error:
|
||||
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.
|
||||
|
||||
This method searches through the list of all available providers and
|
||||
|
||||
@@ -23,6 +23,7 @@ from typing import (
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from loguru import logger
|
||||
@@ -39,6 +40,7 @@ from akkudoktoreos.core.types import (
|
||||
ResampleMethod,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
UTC,
|
||||
DateTime,
|
||||
Duration,
|
||||
to_datetime,
|
||||
@@ -278,9 +280,10 @@ class DatabaseBackendABC(ABC, ConfigMixin, SingletonMixin):
|
||||
|
||||
|
||||
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: ...
|
||||
|
||||
@@ -303,15 +306,13 @@ class DatabaseTimestamp(str):
|
||||
|
||||
@classmethod
|
||||
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")
|
||||
|
||||
return cls(dt.in_timezone("UTC").format("YYYYMMDDTHHmmss[Z]"))
|
||||
|
||||
def to_datetime(self) -> DateTime:
|
||||
from pendulum import parse
|
||||
|
||||
return parse(self)
|
||||
return to_datetime(self, in_timezone="UTC")
|
||||
|
||||
|
||||
class _DatabaseTimestampUnbound(str):
|
||||
@@ -801,6 +802,19 @@ class DatabaseRecordProtocolMixin(
|
||||
|
||||
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:
|
||||
"""Serialize a DataRecord to bytes."""
|
||||
if self.database is None:
|
||||
@@ -1404,10 +1418,14 @@ class DatabaseRecordProtocolMixin(
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Indexed records have timestamps, validated when inserted or loaded.
|
||||
# We validate again to be safe for future refactoring/ changes.
|
||||
record = min(
|
||||
candidates,
|
||||
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 (
|
||||
abs(
|
||||
(
|
||||
record.date_time - DatabaseTimestamp.to_datetime(target_timestamp)
|
||||
self._db_require_date_time(record)
|
||||
- DatabaseTimestamp.to_datetime(target_timestamp)
|
||||
).total_seconds()
|
||||
)
|
||||
> half_seconds
|
||||
@@ -1436,7 +1455,7 @@ class DatabaseRecordProtocolMixin(
|
||||
await self._db_ensure_initialized()
|
||||
|
||||
# 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(
|
||||
start_timestamp=db_record_date_time,
|
||||
@@ -1533,7 +1552,9 @@ class DatabaseRecordProtocolMixin(
|
||||
continue
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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:
|
||||
continue
|
||||
@@ -1666,7 +1691,9 @@ class DatabaseRecordProtocolMixin(
|
||||
# Ensure db in memory data and metadata is 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)
|
||||
|
||||
# -----------------------------------------------------
|
||||
@@ -1691,7 +1718,17 @@ class DatabaseRecordProtocolMixin(
|
||||
save_items = []
|
||||
for dt in self._db_dirty_timestamps:
|
||||
record = self._db_record_index.get(dt)
|
||||
if record:
|
||||
if record is None:
|
||||
continue
|
||||
# Do sanity checks on the record - date_time set and no shift since insertion
|
||||
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))
|
||||
@@ -1969,7 +2006,7 @@ class DatabaseRecordProtocolMixin(
|
||||
# run — they are inside the age window but straddle an incomplete bucket.
|
||||
raw_cutoff_epoch = int(raw_cutoff_dt.timestamp())
|
||||
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)
|
||||
|
||||
# ---- Determine window start (incremental) ------------------------
|
||||
@@ -2001,7 +2038,7 @@ class DatabaseRecordProtocolMixin(
|
||||
# overwritten with the same values).
|
||||
raw_start_epoch = int(raw_window_start_dt.timestamp())
|
||||
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_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.
|
||||
# If every record already sits on an interval boundary, nothing to do.
|
||||
# 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 = [
|
||||
r
|
||||
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 = [
|
||||
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:
|
||||
logger.debug(
|
||||
@@ -2065,8 +2108,8 @@ class DatabaseRecordProtocolMixin(
|
||||
# Process chronologically so the earliest record's values win when
|
||||
# multiple records floor to the same bucket.
|
||||
snapped_bucket: dict[int, dict[str, Any]] = {}
|
||||
for r in sorted(records_in_window, key=lambda x: x.date_time):
|
||||
ts_epoch = int(r.date_time.timestamp())
|
||||
for r in sorted(records_in_window, key=lambda r: cast(DateTime, r.date_time)):
|
||||
ts_epoch = int(cast(DateTime, r.date_time).timestamp())
|
||||
snapped_epoch = (ts_epoch // interval_sec) * interval_sec
|
||||
bucket = snapped_bucket.setdefault(snapped_epoch, {})
|
||||
for key in self.record_keys_writable:
|
||||
@@ -2089,7 +2132,7 @@ class DatabaseRecordProtocolMixin(
|
||||
for snapped_epoch, values in snapped_bucket.items():
|
||||
if not values:
|
||||
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)
|
||||
await self.db_insert_record(record, mark_dirty=True)
|
||||
|
||||
@@ -2148,7 +2191,7 @@ class DatabaseRecordProtocolMixin(
|
||||
while first_bucket_epoch < int(window_start_dt.timestamp()):
|
||||
first_bucket_epoch += interval_sec
|
||||
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))
|
||||
]
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class EnergyManagement(
|
||||
def start_datetime(self) -> DateTime:
|
||||
"""The starting datetime of the current or latest energy management."""
|
||||
if EnergyManagement._start_datetime is None:
|
||||
EnergyManagement.set_start_datetime()
|
||||
return EnergyManagement.set_start_datetime()
|
||||
return EnergyManagement._start_datetime
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Optional, cast
|
||||
|
||||
import pendulum
|
||||
from loguru import logger
|
||||
@@ -176,8 +176,8 @@ def read_file_log(
|
||||
raise FileNotFoundError("Log file not found")
|
||||
|
||||
try:
|
||||
from_dt = pendulum.parse(from_time) if from_time else None
|
||||
to_dt = pendulum.parse(to_time) if to_time else None
|
||||
from_dt = cast(pendulum.DateTime, pendulum.parse(from_time)) if from_time else None
|
||||
to_dt = cast(pendulum.DateTime, pendulum.parse(to_time)) if to_time else None
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid date/time format: {e}")
|
||||
|
||||
@@ -192,7 +192,7 @@ def read_file_log(
|
||||
return False
|
||||
if from_dt or to_dt:
|
||||
try:
|
||||
log_time = pendulum.parse(log["time"])
|
||||
log_time = cast(pendulum.DateTime, pendulum.parse(log["time"]))
|
||||
except Exception:
|
||||
return False
|
||||
if from_dt and log_time < from_dt:
|
||||
|
||||
@@ -19,7 +19,7 @@ class LoggingCommonSettings(SettingsBaseModel):
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Logging level for API response.",
|
||||
"examples": LOGGING_LEVELS,
|
||||
"examples": [*LOGGING_LEVELS],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ class LoggingCommonSettings(SettingsBaseModel):
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Logging level for logging to console.",
|
||||
"examples": LOGGING_LEVELS,
|
||||
"examples": [*LOGGING_LEVELS],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ class LoggingCommonSettings(SettingsBaseModel):
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Logging level for logging to file.",
|
||||
"examples": LOGGING_LEVELS,
|
||||
"examples": [*LOGGING_LEVELS],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -20,13 +20,17 @@ import uuid
|
||||
import weakref
|
||||
from copy import deepcopy
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Self,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
get_origin,
|
||||
)
|
||||
@@ -39,6 +43,7 @@ from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
GetPydanticSchema,
|
||||
PrivateAttr,
|
||||
RootModel,
|
||||
ValidationError,
|
||||
@@ -49,6 +54,7 @@ from pydantic.fields import ComputedFieldInfo, FieldInfo
|
||||
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
DateTime,
|
||||
Duration,
|
||||
to_datetime,
|
||||
to_duration,
|
||||
to_timezone,
|
||||
@@ -415,7 +421,7 @@ class PydanticModelNestedValueMixin:
|
||||
# If this is the final key, set the value
|
||||
if is_final_key:
|
||||
try:
|
||||
model.validate_and_set(key, value)
|
||||
getattr(model, "validate_and_set")(key, value)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error updating model: {e}") from e
|
||||
return
|
||||
@@ -549,10 +555,10 @@ class PydanticModelNestedValueMixin:
|
||||
if not inspect.isclass(model):
|
||||
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__}'.")
|
||||
|
||||
field_annotation = model.model_fields[key].annotation # type: ignore[attr-defined]
|
||||
field_annotation = model.model_fields[key].annotation
|
||||
if not field_annotation:
|
||||
raise TypeError(
|
||||
f"Missing type annotation for field '{key}' in model '{model.__name__}'."
|
||||
@@ -563,6 +569,8 @@ class PydanticModelNestedValueMixin:
|
||||
|
||||
while queue:
|
||||
annotation = queue.pop(0)
|
||||
if isinstance(annotation, TypeVar):
|
||||
annotation = annotation.__bound__ or Any
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
|
||||
@@ -679,7 +687,7 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
"""Resets the fields to their default values."""
|
||||
for field_name, field_info in self.__class__.model_fields.items():
|
||||
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:
|
||||
default_value = field_info.default
|
||||
try:
|
||||
@@ -707,7 +715,7 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
return self.model_dump()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: Type["PydanticBaseModel"], data: dict) -> "PydanticBaseModel":
|
||||
def from_dict(cls, data: dict) -> Self:
|
||||
"""Create a PydanticBaseModel instance from a dictionary.
|
||||
|
||||
Args:
|
||||
@@ -735,7 +743,7 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
return self.model_dump_json()
|
||||
|
||||
@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.
|
||||
|
||||
Args:
|
||||
@@ -926,6 +934,10 @@ class PydanticBaseModel(PydanticModelNestedValueMixin, BaseModel):
|
||||
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):
|
||||
"""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")
|
||||
@classmethod
|
||||
def validate_root(
|
||||
cls, value: Dict[str, Union[str, List[Union[float, int, str, None]]]]
|
||||
) -> Dict[str, Union[str, List[Union[float, int, str, None]]]]:
|
||||
def validate_root(cls, value: dict[str, Any]) -> DateTimeDataValues:
|
||||
# Validate that all keys are strings
|
||||
if not all(isinstance(k, str) for k in value.keys()):
|
||||
raise ValueError("All keys in the dictionary must be strings.")
|
||||
@@ -977,7 +992,7 @@ class PydanticDateTimeData(RootModel):
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
@@ -1176,8 +1191,8 @@ class PydanticDateTimeDataFrame(PydanticBaseModel):
|
||||
df[col] = df[col].dt.tz_convert(resolved_tz)
|
||||
|
||||
return cls(
|
||||
data=df.to_dict(orient="index"),
|
||||
dtypes={col: str(dtype) for col, dtype in df.dtypes.items()},
|
||||
data=cast(dict[str, dict[str, Any]], df.to_dict(orient="index")),
|
||||
dtypes=cast(dict[str, str], {col: str(dtype) for col, dtype in df.dtypes.items()}),
|
||||
tz=resolved_tz,
|
||||
datetime_columns=datetime_columns,
|
||||
)
|
||||
@@ -1401,10 +1416,10 @@ class PydanticDateTimeSeries(PydanticBaseModel):
|
||||
series.index = index
|
||||
|
||||
if len(index) > 0:
|
||||
tz = to_datetime(series.index[0]).timezone.name
|
||||
tz = to_datetime(series.index[0]).timezone_name
|
||||
|
||||
return cls(
|
||||
data=series.to_dict(),
|
||||
data=cast(dict[str, Any], series.to_dict()),
|
||||
dtype=str(series.dtype),
|
||||
tz=tz,
|
||||
)
|
||||
|
||||
@@ -106,7 +106,7 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
||||
def validate_and_sort_charge_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
|
||||
# None means fallback to default values
|
||||
if v is None:
|
||||
return BATTERY_DEFAULT_CHARGE_RATES.copy()
|
||||
return np.asarray(BATTERY_DEFAULT_CHARGE_RATES, dtype=float)
|
||||
|
||||
# Convert to numpy array
|
||||
if isinstance(v, str):
|
||||
@@ -345,10 +345,10 @@ class DevicesCommonSettings(SettingsBaseModel):
|
||||
|
||||
if self.max_batteries and 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:
|
||||
for electric_vehicle in self.electric_vehicles:
|
||||
keys.extend(electric_vehicle.measurement_keys)
|
||||
keys.extend(electric_vehicle.measurement_keys or [])
|
||||
return keys
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class MeasurementDataRecord(DataRecord):
|
||||
return keys
|
||||
|
||||
|
||||
class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
||||
class Measurement(SingletonMixin, DataImportMixin, DataSequence[MeasurementDataRecord]):
|
||||
"""Singleton class that holds measurement data records.
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
energy_mr_array = await self.key_to_array(
|
||||
@@ -237,6 +239,8 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence):
|
||||
end_datetime = await self.max_datetime()
|
||||
if end_datetime:
|
||||
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)
|
||||
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.elect_price_hourly = np.array(parameters.electricity_price_per_wh, float)
|
||||
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)
|
||||
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
|
||||
ac_charge_hours = self.simulation.ac_charge_hours
|
||||
if ac_charge_hours is None:
|
||||
ac_charge_hours = []
|
||||
else:
|
||||
ac_charge_hours = ac_charge_hours.tolist()
|
||||
dc_charge_hours = self.simulation.dc_charge_hours
|
||||
if dc_charge_hours is None:
|
||||
dc_charge_hours = []
|
||||
else:
|
||||
dc_charge_hours = dc_charge_hours.tolist()
|
||||
discharge = self.simulation.bat_discharge_hours
|
||||
if discharge is None:
|
||||
discharge = []
|
||||
else:
|
||||
discharge = discharge.tolist()
|
||||
ac_charge_hours = (
|
||||
self.simulation.ac_charge_hours.tolist()
|
||||
if self.simulation.ac_charge_hours is not None
|
||||
else []
|
||||
)
|
||||
dc_charge_hours = (
|
||||
self.simulation.dc_charge_hours.tolist()
|
||||
if self.simulation.dc_charge_hours is not None
|
||||
else []
|
||||
)
|
||||
discharge = (
|
||||
self.simulation.bat_discharge_hours.tolist()
|
||||
if self.simulation.bat_discharge_hours is not None
|
||||
else []
|
||||
)
|
||||
|
||||
return GeneticSolution(
|
||||
**{
|
||||
|
||||
@@ -62,10 +62,10 @@ class GeneticCommonSettings(SettingsBaseModel):
|
||||
# --- Penalties (existing) -------------------------------------------------
|
||||
|
||||
penalties: dict[str, Union[float, int, str]] = Field(
|
||||
default_factory=lambda: {
|
||||
"ev_soc_miss": 10,
|
||||
"ac_charge_break_even": 1.0,
|
||||
},
|
||||
default_factory=lambda: dict[str, float | int | str](
|
||||
ev_soc_miss=10,
|
||||
ac_charge_break_even=1.0,
|
||||
),
|
||||
json_schema_extra={
|
||||
"description": "Penalty parameters used in fitness evaluation.",
|
||||
"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
|
||||
line_style = line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
||||
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
|
||||
|
||||
# Format the time axis
|
||||
@@ -178,8 +182,15 @@ class GeneticVisualizationReport(ConfigMixin):
|
||||
# Add vertical line for the current date if within the axis range
|
||||
current_time = pendulum.now(self.config.general.timezone)
|
||||
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
|
||||
plt.axvline(current_time, color="r", linestyle="--", label="Now")
|
||||
plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom")
|
||||
plt.axvline(mdates.date2num(current_time), color="r", linestyle="--", label="Now")
|
||||
plt.text(
|
||||
mdates.date2num(current_time),
|
||||
plt.ylim()[1],
|
||||
"Now",
|
||||
color="r",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
)
|
||||
|
||||
# Add a second x-axis on top
|
||||
ax1 = plt.gca()
|
||||
@@ -191,7 +202,9 @@ class GeneticVisualizationReport(ConfigMixin):
|
||||
# ax2.set_xticks(timestamps[::48]) # Set ticks every 12 hours
|
||||
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::48]])
|
||||
# ax2.set_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks
|
||||
ax2.set_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) // 12]])
|
||||
if x2label:
|
||||
@@ -251,7 +264,13 @@ class GeneticVisualizationReport(ConfigMixin):
|
||||
line_style = (
|
||||
line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
||||
) # 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.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.optimization.genetic0.genetic0params import (
|
||||
Genetic0EnergyManagementParameters,
|
||||
Genetic0OptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic0.genetic0solution import (
|
||||
Genetic0SimulationResult,
|
||||
@@ -128,7 +129,7 @@ class Genetic0Simulation(PydanticBaseModel):
|
||||
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_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)
|
||||
else np.full(len(self.load_energy_array), parameters.feed_in_tariff_per_wh, float)
|
||||
)
|
||||
@@ -741,7 +742,7 @@ class Genetic0Optimization(OptimizationBase):
|
||||
def evaluate(
|
||||
self,
|
||||
individual: list[int],
|
||||
parameters: Genetic0EnergyManagementParameters,
|
||||
parameters: Genetic0OptimizationParameters,
|
||||
start_hour: int,
|
||||
worst_case: bool,
|
||||
) -> tuple[float]:
|
||||
@@ -1056,7 +1057,7 @@ class Genetic0Optimization(OptimizationBase):
|
||||
|
||||
def optimize_ems(
|
||||
self,
|
||||
parameters: Genetic0EnergyManagementParameters,
|
||||
parameters: Genetic0OptimizationParameters,
|
||||
start_hour: Optional[int] = None,
|
||||
worst_case: bool = False,
|
||||
ngen: Optional[int] = None,
|
||||
@@ -1208,21 +1209,21 @@ class Genetic0Optimization(OptimizationBase):
|
||||
)
|
||||
|
||||
# Simulation may have changed something, use simulation values
|
||||
ac_charge_hours = self.simulation.ac_charge_hours
|
||||
if ac_charge_hours is None:
|
||||
ac_charge_hours = []
|
||||
else:
|
||||
ac_charge_hours = ac_charge_hours.tolist()
|
||||
dc_charge_hours = self.simulation.dc_charge_hours
|
||||
if dc_charge_hours is None:
|
||||
dc_charge_hours = []
|
||||
else:
|
||||
dc_charge_hours = dc_charge_hours.tolist()
|
||||
discharge = self.simulation.bat_discharge_hours
|
||||
if discharge is None:
|
||||
discharge = []
|
||||
else:
|
||||
discharge = discharge.tolist()
|
||||
ac_charge_hours = (
|
||||
self.simulation.ac_charge_hours.tolist()
|
||||
if self.simulation.ac_charge_hours is not None
|
||||
else []
|
||||
)
|
||||
dc_charge_hours = (
|
||||
self.simulation.dc_charge_hours.tolist()
|
||||
if self.simulation.dc_charge_hours is not None
|
||||
else []
|
||||
)
|
||||
discharge = (
|
||||
self.simulation.bat_discharge_hours.tolist()
|
||||
if self.simulation.bat_discharge_hours is not None
|
||||
else []
|
||||
)
|
||||
|
||||
return Genetic0Solution(
|
||||
**{
|
||||
|
||||
@@ -52,10 +52,10 @@ class Genetic0CommonSettings(SettingsBaseModel):
|
||||
# --- Penalties (existing) -------------------------------------------------
|
||||
|
||||
penalties: dict[str, Union[float, int, str]] = Field(
|
||||
default_factory=lambda: {
|
||||
"ev_soc_miss": 10,
|
||||
"ac_charge_break_even": 1.0,
|
||||
},
|
||||
default_factory=lambda: dict[str, float | int | str](
|
||||
ev_soc_miss=10,
|
||||
ac_charge_break_even=1.0,
|
||||
),
|
||||
json_schema_extra={
|
||||
"description": "Penalty parameters used in fitness evaluation.",
|
||||
"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
|
||||
line_style = line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
||||
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
|
||||
|
||||
# Format the time axis
|
||||
@@ -178,8 +182,15 @@ class Genetic0VisualizationReport(ConfigMixin):
|
||||
# Add vertical line for the current date if within the axis range
|
||||
current_time = pendulum.now(self.config.general.timezone)
|
||||
if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]:
|
||||
plt.axvline(current_time, color="r", linestyle="--", label="Now")
|
||||
plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom")
|
||||
plt.axvline(mdates.date2num(current_time), color="r", linestyle="--", label="Now")
|
||||
plt.text(
|
||||
mdates.date2num(current_time),
|
||||
plt.ylim()[1],
|
||||
"Now",
|
||||
color="r",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
)
|
||||
|
||||
# Add a second x-axis on top
|
||||
ax1 = plt.gca()
|
||||
@@ -191,7 +202,9 @@ class Genetic0VisualizationReport(ConfigMixin):
|
||||
# ax2.set_xticks(timestamps[::48]) # Set ticks every 12 hours
|
||||
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::48]])
|
||||
# ax2.set_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks
|
||||
ax2.set_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) // 12]])
|
||||
if x2label:
|
||||
@@ -251,7 +264,13 @@ class Genetic0VisualizationReport(ConfigMixin):
|
||||
line_style = (
|
||||
line_styles[idx] if line_styles and idx < len(line_styles) else "-"
|
||||
) # 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.xlabel(xlabel) # Set x-axis label
|
||||
|
||||
@@ -104,7 +104,7 @@ class ElecFeeDataRecord(PredictionRecord):
|
||||
return self.elecfee_feedin_amt_wh * 1000.0
|
||||
|
||||
|
||||
class ElecFeeProvider(PredictionProvider):
|
||||
class ElecFeeProvider(PredictionProvider[ElecFeeDataRecord]):
|
||||
"""Abstract base class for electricity fee providers.
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ElecPriceProvider(PricePredictionProviderBase):
|
||||
class ElecPriceProvider(PricePredictionProviderBase[ElecPriceDataRecord]):
|
||||
"""Abstract base class for electricity price providers.
|
||||
|
||||
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
|
||||
|
||||
|
||||
class FeedInTariffProvider(PricePredictionProviderBase):
|
||||
class FeedInTariffProvider(PricePredictionProviderBase[FeedInTariffDataRecord]):
|
||||
"""Abstract base class for feed in tariff providers.
|
||||
|
||||
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(
|
||||
await self.key_to_array(
|
||||
key="feed_in_tariff_wh",
|
||||
end_datetime=self.highest_orig_datetime,
|
||||
end_datetime=to_datetime(self.highest_orig_datetime),
|
||||
fill_method="linear",
|
||||
),
|
||||
dtype=float,
|
||||
dtype=np.float64,
|
||||
)
|
||||
covered_hours = (
|
||||
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.
|
||||
history = await self.key_to_array(
|
||||
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"),
|
||||
fill_method="linear",
|
||||
)
|
||||
|
||||
@@ -137,11 +137,11 @@ class FeedInTariffTibber(FeedInTariffProvider):
|
||||
history = np.asarray(
|
||||
await self.key_to_array(
|
||||
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"),
|
||||
fill_method="linear",
|
||||
),
|
||||
dtype=float,
|
||||
dtype=np.float64,
|
||||
)
|
||||
covered_slots = 0
|
||||
if self.highest_orig_datetime >= self.ems_start_datetime:
|
||||
|
||||
@@ -5,7 +5,7 @@ Notes:
|
||||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import List, Optional
|
||||
from typing import Generic, List, Optional, TypeVar
|
||||
|
||||
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.
|
||||
|
||||
LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
@@ -41,7 +44,7 @@ class LoadProvider(PredictionProvider):
|
||||
"""
|
||||
|
||||
# overload
|
||||
records: List[LoadDataRecord] = Field(
|
||||
records: List[LoadDataRecordT] = Field(
|
||||
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."""
|
||||
|
||||
records: list[LoadAkkudoktorDataRecord] = Field(
|
||||
|
||||
@@ -119,8 +119,7 @@ weather_openmeteo = WeatherOpenMeteo()
|
||||
weather_import = WeatherImport()
|
||||
|
||||
|
||||
def prediction_providers() -> list[
|
||||
Union[
|
||||
PredictionProviderType = Union[
|
||||
ElecFeeFixed,
|
||||
ElecFeeImport,
|
||||
ElecPriceAkkudoktor,
|
||||
@@ -142,6 +141,7 @@ def prediction_providers() -> list[
|
||||
LoadVrm,
|
||||
PVForecastAkkudoktor,
|
||||
PVForecastForecastSolar,
|
||||
PVForecastHomeAssistant,
|
||||
PVForecastImport,
|
||||
PVForecastPVLib,
|
||||
PVForecastPVNode,
|
||||
@@ -151,8 +151,10 @@ def prediction_providers() -> list[
|
||||
WeatherClearOutside,
|
||||
WeatherImport,
|
||||
WeatherOpenMeteo,
|
||||
]
|
||||
]:
|
||||
]
|
||||
|
||||
|
||||
def prediction_providers() -> list[PredictionProviderType]:
|
||||
"""Return list of prediction providers.
|
||||
|
||||
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."""
|
||||
|
||||
providers: list[
|
||||
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(
|
||||
providers: list[PredictionProviderType] = Field(
|
||||
default_factory=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.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import Generic, List, Optional, TypeVar
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, computed_field
|
||||
@@ -20,6 +20,7 @@ from akkudoktoreos.core.dataabc import (
|
||||
DataImportProvider,
|
||||
DataProvider,
|
||||
DataRecord,
|
||||
DataRecordT,
|
||||
DataSequence,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, Duration, to_duration
|
||||
@@ -52,7 +53,10 @@ class PredictionRecord(DataRecord):
|
||||
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.
|
||||
|
||||
The PredictionSequence class provides an ordered, mutable collection of PredictionRecord
|
||||
@@ -90,7 +94,7 @@ class PredictionSequence(DataSequence):
|
||||
"""
|
||||
|
||||
# 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"}
|
||||
)
|
||||
|
||||
@@ -185,7 +189,9 @@ class PredictionStartEndKeepMixin(PredictionABC):
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class PredictionImportProvider(PredictionProvider, DataImportProvider):
|
||||
class PredictionImportProvider(
|
||||
PredictionProvider[DataRecordT], DataImportProvider[DataRecordT], Generic[DataRecordT]
|
||||
):
|
||||
"""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.
|
||||
@@ -264,7 +272,12 @@ class PredictionImportProvider(PredictionProvider, DataImportProvider):
|
||||
pass
|
||||
|
||||
|
||||
class PredictionContainer(PredictionStartEndKeepMixin, DataContainer):
|
||||
PredictionProviderT = TypeVar("PredictionProviderT", bound=PredictionProvider)
|
||||
|
||||
|
||||
class PredictionContainer(
|
||||
PredictionStartEndKeepMixin, DataContainer[PredictionProviderT], Generic[PredictionProviderT]
|
||||
):
|
||||
"""A container for managing multiple PredictionProvider instances.
|
||||
|
||||
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.
|
||||
providers: List[PredictionProvider] = Field(
|
||||
providers: List[PredictionProviderT] = Field(
|
||||
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)."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import cast
|
||||
from typing import Generic, cast
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -9,11 +9,14 @@ from loguru import logger
|
||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||
|
||||
from akkudoktoreos.core.coreabc import PredictionMixin
|
||||
from akkudoktoreos.core.dataabc import DataRecordT
|
||||
from akkudoktoreos.prediction.predictionabc import PredictionProvider
|
||||
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.
|
||||
|
||||
Subclasses must supply the raw/gross record keys, the fee keys to pull from
|
||||
|
||||
@@ -5,7 +5,7 @@ Notes:
|
||||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import List, Optional
|
||||
from typing import Generic, List, Optional, TypeVar
|
||||
|
||||
from loguru import logger
|
||||
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.
|
||||
|
||||
PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
|
||||
@@ -45,7 +48,7 @@ class PVForecastProvider(PredictionProvider):
|
||||
"""
|
||||
|
||||
# overload
|
||||
records: List[PVForecastDataRecord] = Field(
|
||||
records: List[PVForecastDataRecordT] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={"description": "List of PVForecastDataRecord records"},
|
||||
)
|
||||
|
||||
@@ -188,7 +188,7 @@ class PVForecastAkkudoktorDataRecord(PVForecastDataRecord):
|
||||
return self.pvforecast_ac_power
|
||||
|
||||
|
||||
class PVForecastAkkudoktor(PVForecastProvider):
|
||||
class PVForecastAkkudoktor(PVForecastProvider[PVForecastAkkudoktorDataRecord]):
|
||||
"""Fetch and process PV forecast data from akkudoktor.net.
|
||||
|
||||
PVForecastAkkudoktor is a singleton-based class that retrieves weather forecast data
|
||||
|
||||
@@ -72,6 +72,8 @@ class PVForecastForecastSolar(PVForecastProvider):
|
||||
return to_datetime(s)
|
||||
tz = iana_tz or str(self.config.general.timezone)
|
||||
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())
|
||||
|
||||
def _plane_url(self, plane: Any) -> str:
|
||||
|
||||
@@ -100,6 +100,8 @@ class PVForecastPVNode(PVForecastProvider):
|
||||
tz = iana_tz or str(self.config.general.timezone)
|
||||
# Interpret the naive wall-clock string AS local time in tz, then resolve.
|
||||
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())
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
return
|
||||
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(
|
||||
data=data,
|
||||
index=pd.DatetimeIndex(
|
||||
pd.date_range(
|
||||
start=self.ems_start_datetime,
|
||||
end=self.end_datetime,
|
||||
end=end_datetime,
|
||||
freq="1h",
|
||||
inclusive="left",
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ Notes:
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
@@ -232,7 +232,7 @@ class WeatherClearOutside(WeatherProvider):
|
||||
p_detail_tables.pop(0)
|
||||
|
||||
# Create clearout data
|
||||
clearout_data = {}
|
||||
clearout_data: dict[str, Any] = {}
|
||||
# Number of detail values. On last day may be less than 24.
|
||||
detail_values_count = None
|
||||
# Add data values
|
||||
@@ -258,7 +258,7 @@ class WeatherClearOutside(WeatherProvider):
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Scrape the detail values
|
||||
detail_data = []
|
||||
detail_data: list[float | str] = []
|
||||
extra_detail_name = None
|
||||
extra_detail_data = []
|
||||
for p_detail_value in p_detail_values:
|
||||
@@ -281,9 +281,10 @@ class WeatherClearOutside(WeatherProvider):
|
||||
and hasattr(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:
|
||||
value_str = p_detail_value.get_text()
|
||||
value: float | str
|
||||
try:
|
||||
value = float(value_str)
|
||||
except ValueError:
|
||||
@@ -336,9 +337,9 @@ class WeatherClearOutside(WeatherProvider):
|
||||
if key is None:
|
||||
continue
|
||||
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]
|
||||
if corr_factor:
|
||||
value = value * corr_factor
|
||||
setattr(weather_record, key, value)
|
||||
record_value = record_value * corr_factor
|
||||
setattr(weather_record, key, record_value)
|
||||
await self.insert_by_datetime(weather_record)
|
||||
|
||||
@@ -342,12 +342,15 @@ class WeatherOpenMeteo(WeatherProvider):
|
||||
return
|
||||
|
||||
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(
|
||||
data=data,
|
||||
index=pd.DatetimeIndex(
|
||||
pd.date_range(
|
||||
start=self.ems_start_datetime,
|
||||
end=self.end_datetime,
|
||||
end=end_datetime,
|
||||
freq="1h",
|
||||
inclusive="left",
|
||||
)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
# Module taken from https://github.com/koaning/fh-altair
|
||||
# MIT license
|
||||
from typing import Optional
|
||||
from typing import Callable, Optional, cast
|
||||
|
||||
from bokeh.embed import components
|
||||
from bokeh.models import Plot
|
||||
from bokeh.models.annotations import Title
|
||||
from bokeh.plotting import figure
|
||||
from bokeh.resources import INLINE
|
||||
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
|
||||
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:
|
||||
plot.background_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:
|
||||
ax.axis_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:
|
||||
plot.background_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:
|
||||
ax.axis_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:
|
||||
kwargs[field_name] = field_info.default
|
||||
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:
|
||||
required_missing.append(field_name)
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_f
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
if not regular_field:
|
||||
if not regular_field or not isinstance(field_info, FieldInfo):
|
||||
return "N/A"
|
||||
|
||||
# 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
|
||||
elif field_info.default_factory is not None:
|
||||
try:
|
||||
val = field_info.default_factory()
|
||||
val = field_info.get_default(call_default_factory=True)
|
||||
except Exception:
|
||||
return ""
|
||||
else:
|
||||
@@ -250,12 +250,12 @@ def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple
|
||||
|
||||
|
||||
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]:
|
||||
"""Generate configuration details based on provided values and model metadata.
|
||||
|
||||
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_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:
|
||||
nonlocal values, values_prefix
|
||||
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, [])
|
||||
found_basic = False
|
||||
|
||||
@@ -9,6 +9,7 @@ from fasthtml.common import FT, Div, NotStr
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.renderer import RendererHTML
|
||||
from markdown_it.token import Token
|
||||
from markdown_it.utils import OptionsDict
|
||||
from monsterui.foundations import stringify
|
||||
|
||||
# Where to find the static data assets
|
||||
@@ -42,7 +43,7 @@ def file_to_data_uri(file_path: Path) -> str:
|
||||
|
||||
|
||||
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:
|
||||
"""Custom renderer for Markdown headings with MonsterUI styling."""
|
||||
if tokens[idx].markup == "#":
|
||||
@@ -63,7 +64,7 @@ def render_heading(
|
||||
|
||||
|
||||
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:
|
||||
"""Custom renderer for Markdown paragraphs with MonsterUI styling."""
|
||||
tokens[idx].attrSet("class", "leading-7 [&:not(:first-child)]:mt-6")
|
||||
@@ -71,28 +72,30 @@ def render_paragraph(
|
||||
|
||||
|
||||
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:
|
||||
"""Custom renderer for Markdown blockquotes with MonsterUI styling."""
|
||||
tokens[idx].attrSet("class", "mt-6 border-l-2 pl-6 italic border-primary")
|
||||
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."""
|
||||
tokens[idx].attrSet("class", "my-6 ml-6 list-disc [&>li]:mt-2")
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
|
||||
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:
|
||||
"""Custom renderer for Markdown images with MonsterUI styling."""
|
||||
token = tokens[idx]
|
||||
src = token.attrGet("src")
|
||||
alt = token.content or ""
|
||||
|
||||
if src:
|
||||
if isinstance(src, str) and src:
|
||||
pos = src.find(ASSETS_PREFIX)
|
||||
if pos != -1:
|
||||
asset_rel = src[pos + len(ASSETS_PREFIX) :]
|
||||
@@ -107,12 +110,14 @@ def render_image(
|
||||
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."""
|
||||
token = tokens[idx]
|
||||
href = token.attrGet("href")
|
||||
|
||||
if href:
|
||||
if isinstance(href, str) and href:
|
||||
pos = href.find(ASSETS_PREFIX)
|
||||
if pos != -1:
|
||||
asset_rel = href[pos + len(ASSETS_PREFIX) :]
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Optional, Union
|
||||
import pandas as pd
|
||||
import requests
|
||||
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
|
||||
from bokeh.plotting import figure
|
||||
from loguru import logger
|
||||
from monsterui.franken import (
|
||||
Card,
|
||||
@@ -28,7 +27,11 @@ from akkudoktoreos.core.emplan import (
|
||||
OMBCInstruction,
|
||||
)
|
||||
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.context import request_url_for
|
||||
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"
|
||||
start_datetime = "unknown"
|
||||
|
||||
plot = figure(
|
||||
plot = create_figure(
|
||||
title=f"Optimization Solution - last run: {last_run_datetime}",
|
||||
x_axis_type="datetime",
|
||||
x_axis_label=f"Datetime [localtime {date_time_tz}] - start: {start_datetime}",
|
||||
y_axis_label="Power [W]",
|
||||
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,
|
||||
)
|
||||
|
||||
plot.extra_y_ranges = {
|
||||
"energy": Range1d(energy_wh_min, energy_wh_max), # y2
|
||||
"factor": Range1d(factor_min, factor_max), # y3
|
||||
"amt_kwh": Range1d(amt_kwh_min, amt_kwh_max), # y4
|
||||
"amt": Range1d(amt_min, amt_max), # y5
|
||||
"energy": Range1d(start=energy_wh_min, end=energy_wh_max), # y2
|
||||
"factor": Range1d(start=factor_min, end=factor_max), # y3
|
||||
"amt_kwh": Range1d(start=amt_kwh_min, end=amt_kwh_max), # y4
|
||||
"amt": Range1d(start=amt_min, end=amt_max), # y5
|
||||
}
|
||||
# y2 axis
|
||||
y2_axis = LinearAxis(y_range_name="energy", axis_label="Energy [Wh]")
|
||||
@@ -536,7 +539,7 @@ def InstructionCard(
|
||||
)
|
||||
):
|
||||
# This is a battery
|
||||
if instruction.operation_mode_id in ("CHARGE",):
|
||||
if getattr(instruction, "operation_mode_id", None) in ("CHARGE",):
|
||||
icon = "battery-charging"
|
||||
else:
|
||||
icon = "battery"
|
||||
|
||||
@@ -3,11 +3,14 @@ from typing import Optional, Union
|
||||
import pandas as pd
|
||||
import requests
|
||||
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
|
||||
from bokeh.plotting import figure
|
||||
from monsterui.franken import FT, Grid, P
|
||||
|
||||
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
|
||||
|
||||
# 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)
|
||||
provider = config["pvforecast"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
plot = create_figure(
|
||||
x_axis_type="datetime",
|
||||
title=f"PV Power Prediction ({provider})",
|
||||
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
||||
@@ -46,7 +49,7 @@ def ElectricityPriceForecast(
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["elecprice"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
plot = create_figure(
|
||||
x_axis_type="datetime",
|
||||
y_range=Range1d(
|
||||
predictions["elecprice_marketprice_kwh"].min() - 0.1,
|
||||
@@ -78,7 +81,7 @@ def WeatherTempAirHumidityForecast(
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["weather"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
plot = create_figure(
|
||||
x_axis_type="datetime",
|
||||
title=f"Air Temperature and Humidity Prediction ({provider})",
|
||||
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
||||
@@ -115,7 +118,7 @@ def WeatherIrradianceForecast(
|
||||
source = ColumnDataSource(predictions)
|
||||
provider = config["weather"]["provider"]
|
||||
|
||||
plot = figure(
|
||||
plot = create_figure(
|
||||
x_axis_type="datetime",
|
||||
title=f"Irradiance Prediction ({provider})",
|
||||
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"]
|
||||
provider = f"{provider}, {year_energy} kWh"
|
||||
|
||||
plot = figure(
|
||||
plot = create_figure(
|
||||
title=f"Load Prediction ({provider})",
|
||||
x_axis_type="datetime",
|
||||
x_axis_label=f"Datetime [localtime {date_time_tz}]",
|
||||
|
||||
@@ -35,6 +35,7 @@ from akkudoktoreos.core.coreabc import (
|
||||
get_resource_registry,
|
||||
singletons_init,
|
||||
)
|
||||
from akkudoktoreos.core.dataabc import DataImportMixin
|
||||
from akkudoktoreos.core.emplan import EnergyManagementPlan, ResourceStatus
|
||||
from akkudoktoreos.core.ems import ems_manage_energy
|
||||
from akkudoktoreos.core.emsettings import EnergyManagementMode
|
||||
@@ -88,7 +89,12 @@ from akkudoktoreos.server.server import (
|
||||
get_host_ip,
|
||||
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
|
||||
@@ -671,6 +677,8 @@ async def fastapi_logging_get_log(
|
||||
"""
|
||||
log_path = get_config().logging.file_path
|
||||
try:
|
||||
if log_path is None:
|
||||
raise ValueError("Log file path is not configured")
|
||||
logs = read_file_log(
|
||||
log_path=log_path,
|
||||
limit=limit,
|
||||
@@ -864,16 +872,16 @@ async def fastapi_measurement_series_get(
|
||||
if processing == SeriesProcessing.RAW:
|
||||
pdseries = await get_measurement().key_to_raw_series(
|
||||
key=key,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
start_datetime=to_datetime(start_datetime) if start_datetime is not None else None,
|
||||
end_datetime=to_datetime(end_datetime) if end_datetime is not None else None,
|
||||
dropna=dropna,
|
||||
)
|
||||
else:
|
||||
pdseries = await get_measurement().key_to_series(
|
||||
key=key,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
interval=interval,
|
||||
start_datetime=to_datetime(start_datetime) if start_datetime is not None else None,
|
||||
end_datetime=to_datetime(end_datetime) if end_datetime is not None else None,
|
||||
interval=to_duration(interval) if interval is not None else None,
|
||||
fill_method=fill_method,
|
||||
resample_method=resample_method,
|
||||
dropna=dropna,
|
||||
@@ -1247,6 +1255,9 @@ async def fastapi_prediction_series_get(
|
||||
Returns:
|
||||
Array
|
||||
"""
|
||||
resolved_end_datetime: DateTime | None
|
||||
resolved_interval: Duration
|
||||
resolved_start_datetime: DateTime | None
|
||||
if key not in get_prediction().record_keys:
|
||||
raise EOSProblem(
|
||||
status=404,
|
||||
@@ -1255,10 +1266,10 @@ async def fastapi_prediction_series_get(
|
||||
)
|
||||
|
||||
if start_datetime is None:
|
||||
start_datetime = get_prediction().ems_start_datetime
|
||||
resolved_start_datetime = get_prediction().ems_start_datetime
|
||||
else:
|
||||
try:
|
||||
start_datetime = to_datetime(start_datetime)
|
||||
resolved_start_datetime = to_datetime(start_datetime)
|
||||
except Exception as e:
|
||||
raise EOSProblem(
|
||||
status=400,
|
||||
@@ -1268,10 +1279,10 @@ async def fastapi_prediction_series_get(
|
||||
) from e
|
||||
|
||||
if end_datetime is None:
|
||||
end_datetime = get_prediction().end_datetime
|
||||
resolved_end_datetime = get_prediction().end_datetime
|
||||
else:
|
||||
try:
|
||||
end_datetime = to_datetime(end_datetime)
|
||||
resolved_end_datetime = to_datetime(end_datetime)
|
||||
except Exception as e:
|
||||
raise EOSProblem(
|
||||
status=400,
|
||||
@@ -1281,10 +1292,10 @@ async def fastapi_prediction_series_get(
|
||||
) from e
|
||||
|
||||
if interval is None:
|
||||
interval = to_duration("1 hour")
|
||||
resolved_interval = to_duration("1 hour")
|
||||
else:
|
||||
try:
|
||||
interval = to_duration(interval)
|
||||
resolved_interval = to_duration(interval)
|
||||
except Exception as e:
|
||||
raise EOSProblem(
|
||||
status=400,
|
||||
@@ -1297,16 +1308,16 @@ async def fastapi_prediction_series_get(
|
||||
if processing == SeriesProcessing.RAW:
|
||||
pdseries = await get_prediction().key_to_raw_series(
|
||||
key=key,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
start_datetime=resolved_start_datetime,
|
||||
end_datetime=resolved_end_datetime,
|
||||
dropna=dropna,
|
||||
)
|
||||
else:
|
||||
pdseries = await get_prediction().key_to_series(
|
||||
key=key,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
interval=interval,
|
||||
start_datetime=resolved_start_datetime,
|
||||
end_datetime=resolved_end_datetime,
|
||||
interval=resolved_interval,
|
||||
fill_method=fill_method,
|
||||
resample_method=resample_method,
|
||||
dropna=dropna,
|
||||
@@ -1417,24 +1428,26 @@ async def fastapi_prediction_dataframe_get(
|
||||
forecast or reporting queries where alignment to the exact query window is
|
||||
more important than clock-round boundaries.
|
||||
"""
|
||||
resolved_end_datetime: DateTime | None
|
||||
resolved_start_datetime: DateTime | None
|
||||
for key in keys:
|
||||
if key not in get_prediction().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
if start_datetime is None:
|
||||
start_datetime = get_prediction().ems_start_datetime
|
||||
resolved_start_datetime = get_prediction().ems_start_datetime
|
||||
else:
|
||||
start_datetime = to_datetime(start_datetime)
|
||||
resolved_start_datetime = to_datetime(start_datetime)
|
||||
if end_datetime is None:
|
||||
end_datetime = get_prediction().end_datetime
|
||||
resolved_end_datetime = get_prediction().end_datetime
|
||||
else:
|
||||
end_datetime = to_datetime(end_datetime)
|
||||
resolved_end_datetime = to_datetime(end_datetime)
|
||||
|
||||
try:
|
||||
prediction_df = await get_prediction().keys_to_dataframe(
|
||||
keys=keys,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
interval=interval,
|
||||
start_datetime=resolved_start_datetime,
|
||||
end_datetime=resolved_end_datetime,
|
||||
interval=to_duration(interval) if interval is not None else None,
|
||||
fill_method=fill_method,
|
||||
resample_method=resample_method,
|
||||
dropna=dropna,
|
||||
@@ -1536,6 +1549,9 @@ async def fastapi_prediction_list_get(
|
||||
forecast or reporting queries where alignment to the exact query window is
|
||||
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:
|
||||
raise EOSProblem(
|
||||
status=404,
|
||||
@@ -1544,10 +1560,10 @@ async def fastapi_prediction_list_get(
|
||||
)
|
||||
|
||||
if start_datetime is None:
|
||||
start_datetime = get_prediction().ems_start_datetime
|
||||
resolved_start_datetime = get_prediction().ems_start_datetime
|
||||
else:
|
||||
try:
|
||||
start_datetime = to_datetime(start_datetime)
|
||||
resolved_start_datetime = to_datetime(start_datetime)
|
||||
except Exception as e:
|
||||
raise EOSProblem(
|
||||
status=400,
|
||||
@@ -1557,10 +1573,10 @@ async def fastapi_prediction_list_get(
|
||||
) from e
|
||||
|
||||
if end_datetime is None:
|
||||
end_datetime = get_prediction().end_datetime
|
||||
resolved_end_datetime = get_prediction().end_datetime
|
||||
else:
|
||||
try:
|
||||
end_datetime = to_datetime(end_datetime)
|
||||
resolved_end_datetime = to_datetime(end_datetime)
|
||||
except Exception as e:
|
||||
raise EOSProblem(
|
||||
status=400,
|
||||
@@ -1570,10 +1586,10 @@ async def fastapi_prediction_list_get(
|
||||
) from e
|
||||
|
||||
if interval is None:
|
||||
interval = to_duration("1 hour")
|
||||
resolved_interval = to_duration("1 hour")
|
||||
else:
|
||||
try:
|
||||
interval = to_duration(interval)
|
||||
resolved_interval = to_duration(interval)
|
||||
except Exception as e:
|
||||
raise EOSProblem(
|
||||
status=400,
|
||||
@@ -1585,9 +1601,9 @@ async def fastapi_prediction_list_get(
|
||||
try:
|
||||
prediction_array = await get_prediction().key_to_array(
|
||||
key=key,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
interval=interval,
|
||||
start_datetime=resolved_start_datetime,
|
||||
end_datetime=resolved_end_datetime,
|
||||
interval=resolved_interval,
|
||||
fill_method=fill_method,
|
||||
resample_method=resample_method,
|
||||
dropna=dropna,
|
||||
@@ -1655,6 +1671,12 @@ async def fastapi_prediction_import_provider(
|
||||
cause=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)
|
||||
provider.update_datetime = to_datetime(in_timezone=get_config().general.timezone)
|
||||
|
||||
@@ -2192,8 +2214,8 @@ async def fastapi_optimize(
|
||||
)
|
||||
|
||||
# Create compatible solution.
|
||||
legacy_solution = Genetic0SolutionLegacy(
|
||||
**{
|
||||
legacy_solution = Genetic0SolutionLegacy.model_validate(
|
||||
{
|
||||
"ac_charge": solution.ac_charge,
|
||||
"dc_charge": solution.dc_charge,
|
||||
"discharge_allowed": solution.discharge_allowed,
|
||||
@@ -2396,7 +2418,7 @@ def run_eos() -> None:
|
||||
port=config_eos.server.port,
|
||||
log_level=uv_log_level,
|
||||
access_log=True, # Fix server access logging to True
|
||||
reload=config_eos.server.reload,
|
||||
reload=bool(config_eos.server.reload),
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import html
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
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(
|
||||
request=request,
|
||||
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(
|
||||
request=request,
|
||||
status=exc.status_code,
|
||||
status=http_exc.status_code,
|
||||
title="HTTP Error",
|
||||
detail=str(exc.detail),
|
||||
detail=str(http_exc.detail),
|
||||
cause=exc,
|
||||
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(
|
||||
request=request,
|
||||
status=422,
|
||||
|
||||
@@ -4,10 +4,14 @@ import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, MutableMapping, Optional
|
||||
from typing import TYPE_CHECKING, Any, MutableMapping, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from loguru import Record
|
||||
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_config
|
||||
from akkudoktoreos.server.server import (
|
||||
validate_ip_or_hostname,
|
||||
@@ -99,7 +103,7 @@ def _emit_drop_warning() -> None:
|
||||
|
||||
|
||||
def patch_loguru_record(
|
||||
record: MutableMapping[str, Any],
|
||||
record: "Record | MutableMapping[str, Any]",
|
||||
*,
|
||||
file_name: str,
|
||||
file_path: str,
|
||||
|
||||
@@ -124,7 +124,12 @@ def wait_for_port_free(port: int, timeout: int = 0, waiting_app_name: str = "App
|
||||
|
||||
try:
|
||||
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:
|
||||
process = psutil.Process(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 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
|
||||
from loguru import logger
|
||||
from pendulum import UTC as UTC
|
||||
from pendulum.tz.timezone import Timezone
|
||||
from pydantic import (
|
||||
GetCoreSchemaHandler,
|
||||
)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -184,7 +198,7 @@ class Time(pendulum.Time):
|
||||
# Bypass __init__ and __new__ by directly casting the type
|
||||
time_obj.__class__ = cls # This is safe since Time inherits from pendulum.Time
|
||||
|
||||
return time_obj
|
||||
return cast(Time, time_obj)
|
||||
|
||||
@classmethod
|
||||
def _serialize(cls, value: Optional["Time"]) -> str:
|
||||
@@ -224,7 +238,7 @@ class Time(pendulum.Time):
|
||||
if self.tzinfo and other.tzinfo:
|
||||
# Convert both to UTC for comparison
|
||||
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) == (
|
||||
other_utc.hour,
|
||||
other_utc.minute,
|
||||
@@ -259,7 +273,9 @@ class Time(pendulum.Time):
|
||||
"""Convert to UTC timezone."""
|
||||
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."""
|
||||
if isinstance(timezone, str):
|
||||
timezone = pendulum.timezone(timezone)
|
||||
@@ -267,7 +283,9 @@ class Time(pendulum.Time):
|
||||
if self.is_aware():
|
||||
# For timezone conversion, we need a reference date
|
||||
# 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 = dt.in_timezone(timezone) # Convert to target timezone
|
||||
t = dt.time() # Extract naiv time component
|
||||
@@ -316,7 +334,7 @@ class Time(pendulum.Time):
|
||||
return self.format(time_format)
|
||||
|
||||
@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."""
|
||||
if tz:
|
||||
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.
|
||||
|
||||
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")
|
||||
|
||||
# Extract timezone information first
|
||||
timezone_info = None
|
||||
timezone_info: pendulum.Timezone | pendulum.FixedTimezone | None = None
|
||||
time_part = time_str
|
||||
|
||||
# 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
|
||||
# For timezone conversion, we need a reference date
|
||||
# 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 = dt.in_timezone(timezone) # Convert to target timezone
|
||||
t = dt.time() # Extract time component (always naive)
|
||||
@@ -746,7 +766,7 @@ def to_time(
|
||||
tz_name = value.tzinfo.tzname(value)
|
||||
# Safely get Pendulum timezone
|
||||
try:
|
||||
timezone = pendulum.timezone(tz_name)
|
||||
timezone = pendulum.timezone(cast(str, tz_name))
|
||||
except Exception:
|
||||
# fallback to fixed offset if tz_name is something like 'UTC+02:00'
|
||||
utc_offset = value.tzinfo.utcoffset(value)
|
||||
@@ -754,7 +774,7 @@ def to_time(
|
||||
utc_offset_total_seconds = 0.0
|
||||
else:
|
||||
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)
|
||||
return finalize(pdt.time())
|
||||
|
||||
@@ -792,21 +812,25 @@ def to_time(
|
||||
|
||||
# Fallback to pendulum's parser
|
||||
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())
|
||||
except Exception as e:
|
||||
logger.trace(f"Pendulum parser failed for '{value}': {e}")
|
||||
|
||||
# Try parsing with ISO time prefix
|
||||
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())
|
||||
except Exception as e:
|
||||
logger.trace(f"ISO time parser failed for 'T{value}': {e}")
|
||||
|
||||
# Try parsing as part of a full datetime
|
||||
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())
|
||||
except Exception as 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'
|
||||
"""
|
||||
# Timezone to convert to
|
||||
timezone: Timezone | pendulum.FixedTimezone
|
||||
if in_timezone is None:
|
||||
in_timezone = pendulum.local_timezone()
|
||||
elif not isinstance(in_timezone, Timezone):
|
||||
in_timezone = pendulum.timezone(in_timezone)
|
||||
timezone = pendulum.local_timezone()
|
||||
elif isinstance(in_timezone, Timezone):
|
||||
timezone = in_timezone
|
||||
else:
|
||||
timezone = pendulum.timezone(in_timezone)
|
||||
|
||||
if isinstance(date_input, DateTime):
|
||||
dt = date_input
|
||||
elif isinstance(date_input, Date):
|
||||
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:
|
||||
dt = dt.end_of("day")
|
||||
@@ -937,10 +964,10 @@ def to_datetime(
|
||||
# DateTime input without timezone info
|
||||
try:
|
||||
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)
|
||||
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
|
||||
except ValueError as e:
|
||||
@@ -949,9 +976,9 @@ def to_datetime(
|
||||
else:
|
||||
# DateTime input with timezone info
|
||||
try:
|
||||
dt = pendulum.parse(date_input)
|
||||
dt = cast(pendulum.DateTime, pendulum.parse(date_input))
|
||||
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:
|
||||
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:
|
||||
raise ValueError(f"Date string {date_input} does not match any known formats.")
|
||||
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):
|
||||
dt = pendulum.instance(date_input)
|
||||
elif isinstance(date_input, datetime.date):
|
||||
@@ -988,10 +1017,14 @@ def to_datetime(
|
||||
logger.error(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
|
||||
dt_in_tz = dt.in_timezone(in_timezone)
|
||||
dt_in_tz = dt.in_timezone(timezone)
|
||||
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
|
||||
|
||||
@@ -1158,7 +1191,8 @@ def to_duration(
|
||||
duration = parsed # Already a duration
|
||||
else:
|
||||
# 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:
|
||||
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)
|
||||
"""
|
||||
# Normalize tolerance to seconds
|
||||
tolerance_seconds: float
|
||||
if tolerance is None:
|
||||
tolerance_seconds = 0
|
||||
elif isinstance(tolerance, pendulum.Duration):
|
||||
|
||||
+5
-5
@@ -14,7 +14,7 @@ from contextlib import contextmanager
|
||||
from fnmatch import fnmatch
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Generator, Optional, Union
|
||||
from typing import Callable, Generator, Optional, Union, cast
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
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)
|
||||
assert config_eos.general.data_folder_path == data_folder_path
|
||||
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.logging.file_path == config_default_dirs[-1] / "data/output/eos.log"
|
||||
|
||||
@@ -446,7 +446,7 @@ def cleanup_eos_eosdash(
|
||||
pids: list[int] = []
|
||||
for _ in range(int(server_timeout / 3)):
|
||||
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:
|
||||
process = psutil.Process(conn.pid)
|
||||
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
|
||||
@@ -497,7 +497,7 @@ def cleanup_eos_eosdash(
|
||||
pids = []
|
||||
for _ in range(int(server_timeout / 3)):
|
||||
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:
|
||||
process = psutil.Process(conn.pid)
|
||||
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
|
||||
@@ -766,5 +766,5 @@ def set_other_timezone():
|
||||
yield _set_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
|
||||
|
||||
@@ -170,8 +170,7 @@ async def prepare_optimization_real_parameters() -> Genetic0OptimizationParamete
|
||||
print(f"start_solution: {start_solution}")
|
||||
|
||||
# Define parameters for the optimization problem
|
||||
return Genetic0OptimizationParameters(
|
||||
**{
|
||||
return Genetic0OptimizationParameters.model_validate({
|
||||
"ems": {
|
||||
"price_per_wh_battery": 0e-05,
|
||||
"feed_in_tariff_per_wh": 7e-05,
|
||||
@@ -200,8 +199,7 @@ async def prepare_optimization_real_parameters() -> Genetic0OptimizationParamete
|
||||
},
|
||||
"temperature_forecast": temperature_forecast,
|
||||
"start_solution": start_solution,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
|
||||
@@ -366,8 +364,7 @@ def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
|
||||
start_solution = None
|
||||
|
||||
# Define parameters for the optimization problem
|
||||
return Genetic0OptimizationParameters(
|
||||
**{
|
||||
return Genetic0OptimizationParameters.model_validate({
|
||||
"ems": {
|
||||
"price_per_wh_battery": 0e-05,
|
||||
"feed_in_tariff_per_wh": 7e-05,
|
||||
@@ -396,8 +393,7 @@ def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
|
||||
},
|
||||
"temperature_forecast": temperature_forecast,
|
||||
"start_solution": start_solution,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
def run_optimization(
|
||||
|
||||
@@ -171,8 +171,7 @@ async def prepare_optimization_real_parameters() -> GeneticOptimizationParameter
|
||||
print(f"start_solution: {start_solution}")
|
||||
|
||||
# Define parameters for the optimization problem
|
||||
return GeneticOptimizationParameters(
|
||||
**{
|
||||
return GeneticOptimizationParameters.model_validate({
|
||||
"ems": {
|
||||
"price_per_wh_battery": 0e-05,
|
||||
"feed_in_tariff_per_wh": 7e-05,
|
||||
@@ -201,8 +200,7 @@ async def prepare_optimization_real_parameters() -> GeneticOptimizationParameter
|
||||
},
|
||||
"temperature_forecast": temperature_forecast,
|
||||
"start_solution": start_solution,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
def prepare_optimization_parameters() -> GeneticOptimizationParameters:
|
||||
@@ -367,8 +365,7 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
|
||||
start_solution = None
|
||||
|
||||
# Define parameters for the optimization problem
|
||||
return GeneticOptimizationParameters(
|
||||
**{
|
||||
return GeneticOptimizationParameters.model_validate({
|
||||
"ems": {
|
||||
"price_per_wh_battery": 0e-05,
|
||||
"feed_in_tariff_per_wh": 7e-05,
|
||||
@@ -397,8 +394,7 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
|
||||
},
|
||||
"temperature_forecast": temperature_forecast,
|
||||
"start_solution": start_solution,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
def run_optimization(
|
||||
|
||||
@@ -62,6 +62,7 @@ class TestNodeREDAdapter:
|
||||
await adapter.update_data(force_enable=True)
|
||||
|
||||
mock_get.assert_called_once()
|
||||
assert adapter.update_datetime is not None
|
||||
assert compare_datetimes(adapter.update_datetime, now).approximately_equal
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
+7
-2
@@ -17,7 +17,12 @@ from akkudoktoreos.core.cache import (
|
||||
cache_energy_management,
|
||||
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
|
||||
@@ -257,7 +262,7 @@ class TestCacheFileStore:
|
||||
assert ttl_duration is None
|
||||
|
||||
# -- 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
|
||||
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,
|
||||
"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
|
||||
|
||||
with pytest.raises(ValidationError, match=expected_error):
|
||||
GeneralSettings(**valid_data)
|
||||
GeneralSettings.model_validate(valid_data)
|
||||
|
||||
|
||||
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):
|
||||
"""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",
|
||||
duration=f"{duration_h} hours",
|
||||
**kwargs,
|
||||
)
|
||||
))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -73,10 +73,10 @@ class TestTimeWindowConstruction:
|
||||
|
||||
def test_aware_start_time_stripped_to_naive(self):
|
||||
"""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")),
|
||||
duration="2 hours",
|
||||
)
|
||||
))
|
||||
assert w.start_time.tzinfo is None
|
||||
assert w.start_time.hour == 8
|
||||
|
||||
@@ -375,7 +375,7 @@ class TestFitAndAvailable:
|
||||
|
||||
class TestTimeWindowSequence:
|
||||
def setup_method(self, method):
|
||||
self.seq = TimeWindowSequence(
|
||||
self.seq = TimeWindowSequence[TimeWindow](
|
||||
windows=[
|
||||
make_window(8, 2), # 08:00–10:00
|
||||
make_window(14, 3), # 14:00–17:00
|
||||
@@ -417,15 +417,15 @@ class TestTimeWindowSequence:
|
||||
assert result == pendulum.duration(hours=5)
|
||||
|
||||
def test_empty_sequence_contains_false(self):
|
||||
seq = TimeWindowSequence()
|
||||
seq = TimeWindowSequence[TimeWindow]()
|
||||
assert not seq.contains(naive_dt(2024, 6, 15, 9, 0, 0))
|
||||
|
||||
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
|
||||
|
||||
def test_empty_sequence_available_none(self):
|
||||
seq = TimeWindowSequence()
|
||||
seq = TimeWindowSequence[TimeWindow]()
|
||||
assert seq.available_duration(naive_dt(2024, 6, 15)) is None
|
||||
|
||||
def test_get_applicable_windows(self):
|
||||
@@ -445,7 +445,7 @@ class TestTimeWindowSequence:
|
||||
assert fits[0].start_time.hour == 14
|
||||
|
||||
def test_sort_windows_by_start_time(self):
|
||||
seq = TimeWindowSequence(
|
||||
seq = TimeWindowSequence[TimeWindow](
|
||||
windows=[make_window(14, 1), make_window(8, 1)]
|
||||
)
|
||||
ref = naive_dt(2024, 6, 15)
|
||||
@@ -454,7 +454,7 @@ class TestTimeWindowSequence:
|
||||
assert seq.windows[1].start_time.hour == 14
|
||||
|
||||
def test_add_and_remove_window(self):
|
||||
seq = TimeWindowSequence()
|
||||
seq = TimeWindowSequence[TimeWindow]()
|
||||
w = make_window(10, 1)
|
||||
seq.add_window(w)
|
||||
assert len(seq) == 1
|
||||
@@ -463,7 +463,7 @@ class TestTimeWindowSequence:
|
||||
assert len(seq) == 0
|
||||
|
||||
def test_remove_from_empty_raises(self):
|
||||
seq = TimeWindowSequence()
|
||||
seq = TimeWindowSequence[TimeWindow]()
|
||||
with pytest.raises(IndexError):
|
||||
seq.remove_window(0)
|
||||
|
||||
@@ -487,20 +487,20 @@ class TestTimeWindowSequence:
|
||||
|
||||
class TestValueTimeWindow:
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
def test_inherits_aware_start_time_stripped(self):
|
||||
"""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")),
|
||||
duration="2 hours",
|
||||
value=0.1,
|
||||
)
|
||||
))
|
||||
assert w.start_time.tzinfo is None
|
||||
assert w.start_time.hour == 8
|
||||
|
||||
@@ -509,8 +509,8 @@ class TestValueTimeWindowSequence:
|
||||
def setup_method(self, method):
|
||||
self.seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(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="08:00:00", duration="4 hours", value=0.25)),
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -552,7 +552,7 @@ class TestTimeWindowSequenceToArray:
|
||||
"""
|
||||
|
||||
def setup_method(self, method):
|
||||
self.seq = TimeWindowSequence(
|
||||
self.seq = TimeWindowSequence[TimeWindow](
|
||||
windows=[
|
||||
make_window(8, 2), # 08:00–10:00
|
||||
make_window(14, 3), # 14:00–17:00
|
||||
@@ -697,7 +697,7 @@ class TestTimeWindowSequenceToArray:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_empty_sequence_all_zeros(self):
|
||||
seq = TimeWindowSequence()
|
||||
seq = TimeWindowSequence[TimeWindow]()
|
||||
start = naive_dt(2024, 6, 15, 0)
|
||||
end = naive_dt(2024, 6, 15, 4)
|
||||
arr = seq.to_array(start, end, pendulum.duration(hours=1))
|
||||
@@ -710,7 +710,7 @@ class TestTimeWindowSequenceToArray:
|
||||
|
||||
def test_day_of_week_constraint_respected(self):
|
||||
# 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)
|
||||
tuesday_start = naive_dt(2024, 6, 18, 7)
|
||||
end_offset = pendulum.duration(hours=4)
|
||||
@@ -737,7 +737,7 @@ class TestTimeWindowSequenceToSeries:
|
||||
"""
|
||||
|
||||
def setup_method(self, method):
|
||||
self.seq = TimeWindowSequence(
|
||||
self.seq = TimeWindowSequence[TimeWindow](
|
||||
windows=[
|
||||
make_window(8, 2),
|
||||
make_window(14, 3),
|
||||
@@ -858,7 +858,7 @@ class TestTimeWindowSequenceToSeries:
|
||||
)
|
||||
|
||||
def test_empty_sequence_all_zeros(self):
|
||||
seq = TimeWindowSequence()
|
||||
seq = TimeWindowSequence[TimeWindow]()
|
||||
start = naive_dt(2024, 6, 15, 0)
|
||||
end = naive_dt(2024, 6, 15, 4)
|
||||
|
||||
@@ -885,8 +885,8 @@ class TestValueTimeWindowSequenceToArray:
|
||||
def setup_method(self, method):
|
||||
self.seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(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="08:00:00", duration="4 hours", value=0.25)),
|
||||
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):
|
||||
seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(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="08:00:00", duration="2 hours", value=None)),
|
||||
ValueTimeWindow.model_validate(dict(start_time="12:00:00", duration="2 hours", value=0.5)),
|
||||
]
|
||||
)
|
||||
start = naive_dt(2024, 6, 15, 8)
|
||||
@@ -956,8 +956,8 @@ class TestValueTimeWindowSequenceToArray:
|
||||
def test_dropna_true_none_value_step_omitted(self):
|
||||
seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(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="08:00:00", duration="2 hours", value=None)),
|
||||
ValueTimeWindow.model_validate(dict(start_time="12:00:00", duration="2 hours", value=0.5)),
|
||||
]
|
||||
)
|
||||
start = naive_dt(2024, 6, 15, 8)
|
||||
@@ -1018,8 +1018,8 @@ class TestValueTimeWindowSequenceToArray:
|
||||
def test_overlapping_windows_first_wins(self):
|
||||
seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(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="08:00:00", duration="4 hours", value=0.10)),
|
||||
ValueTimeWindow.model_validate(dict(start_time="09:00:00", duration="4 hours", value=0.99)),
|
||||
]
|
||||
)
|
||||
start = naive_dt(2024, 6, 15, 9)
|
||||
@@ -1040,16 +1040,16 @@ class TestValueTimeWindowSequenceToSeries:
|
||||
def setup_method(self, method):
|
||||
self.seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="08:00:00",
|
||||
duration="4 hours",
|
||||
value=0.25,
|
||||
),
|
||||
ValueTimeWindow(
|
||||
)),
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="18:00:00",
|
||||
duration="4 hours",
|
||||
value=0.35,
|
||||
),
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1096,16 +1096,16 @@ class TestValueTimeWindowSequenceToSeries:
|
||||
def test_dropna_false_none_value_emits_nan(self):
|
||||
seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="08:00:00",
|
||||
duration="2 hours",
|
||||
value=None,
|
||||
),
|
||||
ValueTimeWindow(
|
||||
)),
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="12:00:00",
|
||||
duration="2 hours",
|
||||
value=0.5,
|
||||
),
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1134,16 +1134,16 @@ class TestValueTimeWindowSequenceToSeries:
|
||||
def test_dropna_true_none_value_omits_timestamp(self):
|
||||
seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="08:00:00",
|
||||
duration="2 hours",
|
||||
value=None,
|
||||
),
|
||||
ValueTimeWindow(
|
||||
)),
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="12:00:00",
|
||||
duration="2 hours",
|
||||
value=0.5,
|
||||
),
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1254,16 +1254,16 @@ class TestValueTimeWindowSequenceToSeries:
|
||||
def test_overlapping_windows_first_wins(self):
|
||||
seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="08:00:00",
|
||||
duration="4 hours",
|
||||
value=0.10,
|
||||
),
|
||||
ValueTimeWindow(
|
||||
)),
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="09:00:00",
|
||||
duration="4 hours",
|
||||
value=0.99,
|
||||
),
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1452,7 +1452,7 @@ class TestAlignToIntervalTimezoneInvariance:
|
||||
def test_vtws_naive_floor_utc(self, set_other_timezone):
|
||||
set_other_timezone("UTC")
|
||||
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)
|
||||
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):
|
||||
set_other_timezone()
|
||||
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)
|
||||
end = naive_dt(2024, 6, 15, 10, 10)
|
||||
@@ -1480,11 +1480,11 @@ class TestAlignToIntervalTimezoneInvariance:
|
||||
|
||||
seq = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="08:00:00",
|
||||
duration="2 hours",
|
||||
value=0.25,
|
||||
)
|
||||
))
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import List, Optional, Type
|
||||
import numpy as np
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from pendulum import UTC
|
||||
from pydantic import Field
|
||||
|
||||
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(
|
||||
default_factory=list,
|
||||
json_schema_extra={"description": "List of energy records"},
|
||||
@@ -58,7 +59,7 @@ class EnergySequence(DataSequence):
|
||||
return "energy_test"
|
||||
|
||||
|
||||
class PriceSequence(DataSequence):
|
||||
class PriceSequence(DataSequence[EnergyRecord]):
|
||||
"""Price data — overrides tiers to keep 15-min resolution for 2 weeks."""
|
||||
|
||||
records: List[EnergyRecord] = Field(
|
||||
@@ -78,7 +79,7 @@ class PriceSequence(DataSequence):
|
||||
return [(to_duration("14 days"), to_duration("1 hour"))]
|
||||
|
||||
|
||||
class EnergyProvider(DataProvider):
|
||||
class EnergyProvider(DataProvider[EnergyRecord]):
|
||||
records: List[EnergyRecord] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={"description": "List of energy records"},
|
||||
@@ -101,7 +102,7 @@ class EnergyProvider(DataProvider):
|
||||
return self.provider_id()
|
||||
|
||||
|
||||
class PriceProvider(DataProvider):
|
||||
class PriceProvider(DataProvider[EnergyRecord]):
|
||||
records: List[EnergyRecord] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={"description": "List of price records"},
|
||||
@@ -181,7 +182,7 @@ def _reset_singletons() -> None:
|
||||
"""
|
||||
for cls in (EnergySequence, PriceSequence, EnergyProvider, PriceProvider, EnergyContainer):
|
||||
try:
|
||||
cls.reset_instance()
|
||||
getattr(cls, "reset_instance")()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -691,7 +692,7 @@ class TestDataSequenceCompactIntegrity:
|
||||
# DatabaseTimestamp already imported at top of file
|
||||
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_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]
|
||||
|
||||
@@ -986,7 +987,7 @@ class TestDataSequenceSparseGuard:
|
||||
margin_sec = (max_offset + 2 * interval_minutes + 1) * 60
|
||||
raw_base_epoch = window_end_epoch - margin_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 = []
|
||||
for off in offsets_minutes:
|
||||
|
||||
@@ -37,7 +37,7 @@ class DerivedRecord(DataRecord):
|
||||
return ["dish_washer_emr", "solar_power", "temp"]
|
||||
|
||||
|
||||
class DerivedDataProvider(DataProvider):
|
||||
class DerivedDataProvider(DataProvider[DerivedRecord]):
|
||||
"""Concrete DataProvider for testing."""
|
||||
|
||||
records: List[DerivedRecord] = Field(
|
||||
|
||||
@@ -51,7 +51,7 @@ class DerivedRecord(DataRecord):
|
||||
return ["dish_washer_emr", "solar_power", "temp"]
|
||||
|
||||
|
||||
class DerivedSequence(DataSequence):
|
||||
class DerivedSequence(DataSequence[DerivedRecord]):
|
||||
# overload
|
||||
records: List[DerivedRecord] = Field(
|
||||
default_factory=list, description="List of DerivedRecord records"
|
||||
@@ -65,7 +65,7 @@ class DerivedSequence(DataSequence):
|
||||
return "DerivedSequence"
|
||||
|
||||
|
||||
class DerivedSequence2(DataSequence):
|
||||
class DerivedSequence2(DataSequence[DerivedRecord]):
|
||||
# overload
|
||||
records: List[DerivedRecord] = Field(
|
||||
default_factory=list, description="List of DerivedRecord records"
|
||||
@@ -79,7 +79,7 @@ class DerivedSequence2(DataSequence):
|
||||
return "DerivedSequence2"
|
||||
|
||||
|
||||
class DerivedDataProvider(DataProvider):
|
||||
class DerivedDataProvider(DataProvider[DerivedRecord]):
|
||||
"""A concrete subclass of DataProvider for testing purposes."""
|
||||
|
||||
# overload
|
||||
@@ -108,7 +108,7 @@ class DerivedDataProvider(DataProvider):
|
||||
DerivedDataProvider.provider_updated = True
|
||||
|
||||
|
||||
class DerivedDataImportProvider(DataImportProvider):
|
||||
class DerivedDataImportProvider(DataImportProvider[DerivedRecord]):
|
||||
"""A concrete subclass of DataImportProvider for testing purposes."""
|
||||
|
||||
# overload
|
||||
|
||||
@@ -296,13 +296,13 @@ class TestDataRecord:
|
||||
|
||||
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."""
|
||||
record = DerivedRecord(
|
||||
record = DerivedRecord.model_validate(dict(
|
||||
date_time="2024-01-03T00:00:00+00:00",
|
||||
data_value=42.0,
|
||||
dish_washer_emr=111.1,
|
||||
solar_power=222.2,
|
||||
temp=333.3 # assume `temp` is also a valid configured key
|
||||
)
|
||||
))
|
||||
|
||||
assert record.data_value == 42.0
|
||||
assert record.configured_data == {
|
||||
|
||||
@@ -52,7 +52,7 @@ class DerivedRecord(DataRecord):
|
||||
return ["dish_washer_emr", "solar_power", "temp"]
|
||||
|
||||
|
||||
class DerivedSequence(DataSequence):
|
||||
class DerivedSequence(DataSequence[DerivedRecord]):
|
||||
# overload
|
||||
records: List[DerivedRecord] = Field(
|
||||
default_factory=list, description="List of DerivedRecord records"
|
||||
@@ -66,7 +66,7 @@ class DerivedSequence(DataSequence):
|
||||
return "DerivedSequence"
|
||||
|
||||
|
||||
class DerivedSequence2(DataSequence):
|
||||
class DerivedSequence2(DataSequence[DerivedRecord]):
|
||||
# overload
|
||||
records: List[DerivedRecord] = Field(
|
||||
default_factory=list, description="List of DerivedRecord records"
|
||||
|
||||
@@ -111,7 +111,7 @@ class SampleDataRecord(DataRecord):
|
||||
pressure: float = Field(default=0.0)
|
||||
|
||||
|
||||
class SampleDataSequence(DataSequence):
|
||||
class SampleDataSequence(DataSequence[SampleDataRecord]):
|
||||
"""DataSequence subclass with database support."""
|
||||
records: list[SampleDataRecord] = Field(default_factory=list)
|
||||
|
||||
@@ -123,7 +123,7 @@ class SampleDataSequence(DataSequence):
|
||||
return "SampleDataSequence"
|
||||
|
||||
|
||||
class SampleDataProvider(DataProvider):
|
||||
class SampleDataProvider(DataProvider[SampleDataRecord]):
|
||||
"""DataProvider subclass with database support."""
|
||||
records: list[SampleDataRecord] = Field(default_factory=list)
|
||||
|
||||
@@ -317,7 +317,7 @@ class TestDataSequenceDatabaseProtocol:
|
||||
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)]
|
||||
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):
|
||||
sequence = SampleDataSequence()
|
||||
|
||||
@@ -680,7 +680,7 @@ class SampleDataRecord(DataRecord):
|
||||
pressure: float = Field(default=0.0)
|
||||
|
||||
|
||||
class SampleDataSequence(DataSequence):
|
||||
class SampleDataSequence(DataSequence[SampleDataRecord]):
|
||||
"""DataSequence subclass with database support."""
|
||||
records: list[SampleDataRecord] = Field(default_factory=list)
|
||||
|
||||
@@ -692,7 +692,7 @@ class SampleDataSequence(DataSequence):
|
||||
return "SampleDataSequence"
|
||||
|
||||
|
||||
class SampleDataProvider(DataProvider):
|
||||
class SampleDataProvider(DataProvider[SampleDataRecord]):
|
||||
"""DataProvider subclass with database support."""
|
||||
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_asyncio
|
||||
from numpydantic import NDArray, Shape
|
||||
from pendulum import UTC
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from akkudoktoreos.core.databaseabc import (
|
||||
@@ -57,7 +58,7 @@ class SampleRecord(BaseModel):
|
||||
return self.value
|
||||
raise KeyError(key)
|
||||
|
||||
def model_dump(self) -> dict:
|
||||
def model_dump(self, **kwargs: Any) -> dict:
|
||||
return {"date_time": self.date_time, "value": self.value}
|
||||
|
||||
|
||||
@@ -303,7 +304,7 @@ class SampleSequence(DatabaseRecordProtocolMixin[SampleRecord]):
|
||||
if end_datetime is not None:
|
||||
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
|
||||
):
|
||||
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)
|
||||
generated = list(seq.db_generate_timestamps(db_start, value_count))
|
||||
@@ -390,7 +391,7 @@ class TestDatabaseRecordProtocolMixin:
|
||||
|
||||
for db_dt in generated:
|
||||
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"
|
||||
|
||||
@@ -1047,7 +1048,9 @@ class TestCompactDataIntegrity:
|
||||
interval_sec = 15 * 60
|
||||
expected_window_start = DateTime.fromtimestamp(
|
||||
(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[-1].date_time < cutoff
|
||||
|
||||
+17
-17
@@ -7,7 +7,7 @@ including edge cases, error handling, and timezone behavior.
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import babel
|
||||
@@ -621,7 +621,7 @@ class TestToTime:
|
||||
def test_to_time_invalid_input_type(self):
|
||||
"""Test to_time with invalid input 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):
|
||||
"""Test to_time with invalid hour as integer."""
|
||||
@@ -657,7 +657,7 @@ class TestToTime:
|
||||
def test_to_time_invalid_timezone_type(self):
|
||||
"""Test to_time with invalid timezone type."""
|
||||
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):
|
||||
"""Test to_time preserves microsecond precision."""
|
||||
@@ -727,7 +727,7 @@ class TestTimeUtilityIntegration:
|
||||
test_time: Time
|
||||
|
||||
# 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 model.test_time.hour == 14
|
||||
|
||||
@@ -748,8 +748,8 @@ class TestTimeUtilityIntegration:
|
||||
|
||||
for case in test_cases:
|
||||
# Both should produce the same result
|
||||
direct_result = to_time(case)
|
||||
model_result = TestModel(test_time=case).test_time
|
||||
direct_result = to_time(cast(Any, case))
|
||||
model_result = TestModel.model_validate(dict(test_time=case)).test_time
|
||||
|
||||
assert direct_result.hour == model_result.hour
|
||||
assert direct_result.minute == model_result.minute
|
||||
@@ -770,12 +770,12 @@ class ScheduleModel(PydanticBaseModel):
|
||||
class TestPendulumTypes:
|
||||
|
||||
def test_valid_schedule_model(self):
|
||||
model = ScheduleModel(
|
||||
model = ScheduleModel.model_validate(dict(
|
||||
start_time="14:30:00",
|
||||
run_duration=to_duration("PT2H"),
|
||||
scheduled_at=to_datetime("2025-07-04T09:00:00+02:00"),
|
||||
run_on=to_datetime("2025-07-04")
|
||||
)
|
||||
))
|
||||
|
||||
assert isinstance(model.start_time, pendulum.Time)
|
||||
assert isinstance(model.run_duration, pendulum.Duration)
|
||||
@@ -788,12 +788,12 @@ class TestPendulumTypes:
|
||||
assert model.run_on.to_date_string() == "2025-07-04"
|
||||
|
||||
def test_json_serialization(self):
|
||||
model = ScheduleModel(
|
||||
model = ScheduleModel.model_validate(dict(
|
||||
start_time=pendulum.time(6, 15),
|
||||
run_duration=pendulum.duration(minutes=45),
|
||||
scheduled_at=pendulum.datetime(2025, 7, 4, 6, 15, tz="Europe/Berlin"),
|
||||
run_on=pendulum.date(2025, 7, 4)
|
||||
)
|
||||
))
|
||||
|
||||
json_data = model.model_dump(mode="json")
|
||||
assert "06:15:00" in json_data["start_time"]
|
||||
@@ -809,30 +809,30 @@ class TestPendulumTypes:
|
||||
|
||||
def test_invalid_start_time(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleModel(
|
||||
ScheduleModel.model_validate(dict(
|
||||
start_time="invalid",
|
||||
run_duration="PT1H",
|
||||
scheduled_at="2025-07-04T09:00:00+02:00",
|
||||
run_on="2025-07-04"
|
||||
)
|
||||
))
|
||||
|
||||
def test_invalid_duration(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleModel(
|
||||
ScheduleModel.model_validate(dict(
|
||||
start_time="10:00:00",
|
||||
run_duration="2 hours", # invalid ISO 8601 duration
|
||||
scheduled_at="2025-07-04T09:00:00+02:00",
|
||||
run_on="2025-07-04"
|
||||
)
|
||||
))
|
||||
|
||||
def test_type_coercion(self):
|
||||
dt = pendulum.datetime(2025, 7, 4, 12, 0)
|
||||
model = ScheduleModel(
|
||||
model = ScheduleModel.model_validate(dict(
|
||||
start_time=pendulum.time(12, 0),
|
||||
run_duration=pendulum.duration(hours=3),
|
||||
scheduled_at=dt,
|
||||
run_on=dt.date()
|
||||
)
|
||||
))
|
||||
assert model.scheduled_at.hour == 12
|
||||
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."""
|
||||
set_other_timezone(local_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
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +17,36 @@ DIR_TEST_GENERATED = DIR_TESTDATA / "docs" / "_generated"
|
||||
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!")
|
||||
def test_openapi_spec_current(config_eos, set_other_timezone):
|
||||
"""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.parsers.rst import Directive, Parser, directives
|
||||
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.docstring import GoogleDocstring
|
||||
|
||||
@@ -341,7 +342,7 @@ def test_all_docstrings_rst_compliant():
|
||||
continue
|
||||
|
||||
# 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
|
||||
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.consumption_amt_kwh 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.consumption_percent_amt is not None
|
||||
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.feedin_amt_kwh is not None
|
||||
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.feedin_percent_amt is not None
|
||||
assert len(settings.feedin_percent_amt.windows) == 1
|
||||
@@ -111,24 +111,24 @@ def elecfeefixed_settings():
|
||||
"""
|
||||
consumption_amt_kwh = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(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="00:00", duration="8 hours", value=0.288)),
|
||||
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.34)),
|
||||
]
|
||||
)
|
||||
consumption_percent_amt = ValueTimeWindowSequence(
|
||||
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(
|
||||
windows=[
|
||||
ValueTimeWindow(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="00:00", duration="8 hours", value=0.08)),
|
||||
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.10)),
|
||||
]
|
||||
)
|
||||
feedin_percent_amt = ValueTimeWindowSequence(
|
||||
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(
|
||||
consumption_amt_kwh=ValueTimeWindowSequence(
|
||||
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=[]),
|
||||
feedin_amt_kwh=ValueTimeWindowSequence(
|
||||
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(
|
||||
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)
|
||||
consumption_amt_kwh = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(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="00:00", duration="8 hours", value=0.288)),
|
||||
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.34)),
|
||||
]
|
||||
)
|
||||
consumption_percent_amt = ValueTimeWindowSequence(
|
||||
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(
|
||||
windows=[
|
||||
ValueTimeWindow(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="00:00", duration="8 hours", value=0.08)),
|
||||
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.10)),
|
||||
]
|
||||
)
|
||||
feedin_percent_amt = ValueTimeWindowSequence(
|
||||
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.elecprice_marketprice_amt_kwh is not None
|
||||
assert settings.elecprice_marketprice_amt_kwh.windows is not None
|
||||
@@ -71,16 +71,16 @@ def provider(config_eos):
|
||||
# Create time windows
|
||||
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="00:00",
|
||||
duration="8 hours",
|
||||
value=0.288
|
||||
),
|
||||
ValueTimeWindow(
|
||||
)),
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="08:00",
|
||||
duration="16 hours",
|
||||
value=0.34
|
||||
)
|
||||
))
|
||||
]
|
||||
)
|
||||
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)
|
||||
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="00:00",
|
||||
duration="8 hours",
|
||||
value=0.288 # Night rate
|
||||
),
|
||||
ValueTimeWindow(
|
||||
)),
|
||||
ValueTimeWindow.model_validate(dict(
|
||||
start_time="08:00",
|
||||
duration="16 hours",
|
||||
value=0.34 # Day rate
|
||||
)
|
||||
))
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from akkudoktoreos.core.emplan import (
|
||||
BaseInstruction,
|
||||
CommodityQuantity,
|
||||
DDBCInstruction,
|
||||
EnergyManagementInstruction,
|
||||
EnergyManagementPlan,
|
||||
FRBCInstruction,
|
||||
OMBCInstruction,
|
||||
@@ -30,6 +31,7 @@ class TestEnergyManagementPlan:
|
||||
# Helpers (only used inside the class)
|
||||
# ----------------------------------------------------------------------
|
||||
def _make_instr(self, resource_id, execution_time, duration=None):
|
||||
instr: OMBCInstruction | PEBCInstruction
|
||||
if duration is None:
|
||||
instr = OMBCInstruction(
|
||||
id=resource_id,
|
||||
@@ -169,7 +171,7 @@ class TestEnergyManagementPlan:
|
||||
generated_at=fixed_now,
|
||||
instructions=[]
|
||||
)
|
||||
instrs = [
|
||||
instrs: list[EnergyManagementInstruction] = [
|
||||
DDBCInstruction(
|
||||
id="actuatorA@123",
|
||||
execution_time=fixed_now,
|
||||
|
||||
@@ -265,13 +265,13 @@ class TestAcChargingInSimulation:
|
||||
|
||||
simulation = Genetic0Simulation()
|
||||
simulation.prepare(
|
||||
Genetic0EnergyManagementParameters(
|
||||
Genetic0EnergyManagementParameters.model_validate(dict(
|
||||
pv_prognose_wh=[0.0] * prediction_hours, # No PV
|
||||
strompreis_euro_pro_wh=[0.0003] * prediction_hours, # ~30ct/kWh
|
||||
einspeiseverguetung_euro_pro_wh=0.00008,
|
||||
preis_euro_pro_wh_akku=0.0001,
|
||||
gesamtlast=[1000.0] * prediction_hours, # 1 kW constant load
|
||||
),
|
||||
)),
|
||||
optimization_hours=config_eos.optimization.genetic0.horizon_hours,
|
||||
prediction_hours=prediction_hours,
|
||||
inverter=inverter,
|
||||
|
||||
@@ -239,13 +239,13 @@ def genetic0_simulation(config_eos) -> Genetic0Simulation:
|
||||
# Initialize the energy management system with the respective parameters
|
||||
genetic0_simulation = Genetic0Simulation()
|
||||
genetic0_simulation.prepare(
|
||||
Genetic0EnergyManagementParameters(
|
||||
Genetic0EnergyManagementParameters.model_validate(dict(
|
||||
pv_prognose_wh=pv_prognose_wh,
|
||||
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
||||
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
||||
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
|
||||
gesamtlast=gesamtlast,
|
||||
),
|
||||
)),
|
||||
optimization_hours = config_eos.optimization.genetic0.horizon_hours,
|
||||
prediction_hours = config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
@@ -387,13 +387,13 @@ def genetic0_simulation_2(config_eos) -> Genetic0Simulation:
|
||||
# Initialize the energy management system with the respective parameters
|
||||
simulation = Genetic0Simulation()
|
||||
simulation.prepare(
|
||||
Genetic0EnergyManagementParameters(
|
||||
Genetic0EnergyManagementParameters.model_validate(dict(
|
||||
pv_prognose_wh=pv_prognose_wh,
|
||||
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
||||
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
||||
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
|
||||
gesamtlast=gesamtlast,
|
||||
),
|
||||
)),
|
||||
optimization_hours = config_eos.optimization.genetic0.horizon_hours,
|
||||
prediction_hours = config_eos.prediction.hours,
|
||||
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