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:
Bobby Noelte
2026-09-10 23:20:35 +02:00
committed by GitHub
co-authored by dr-dimitri Normann
parent 5b584cbb57
commit 1abdd345c4
114 changed files with 2217 additions and 1322 deletions
+6 -1
View File
@@ -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
+5 -8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.13
+40 -4
View File
@@ -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:
+7 -5
View File
@@ -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)
+11 -11
View File
@@ -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'. |
:::
+1 -1
View File
@@ -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 -->
+5 -5
View File
@@ -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 -->
+22 -22
View File
@@ -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 -->
+7 -7
View File
@@ -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 -->
+5 -5
View File
@@ -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 -->
+3 -3
View File
@@ -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 -->
+8 -8
View File
@@ -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 -->
+4 -4
View File
@@ -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 -->
+4 -4
View File
@@ -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 -->
+5 -5
View File
@@ -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 -->
+8 -8
View File
@@ -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 -->
+2 -2
View File
@@ -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 -->
+65 -64
View File
@@ -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 -->
+4 -4
View File
@@ -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 -->
+3 -3
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+18 -11
View File
@@ -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)
+4 -1
View File
@@ -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:
+1 -1
View File
@@ -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[
+8 -5
View File
@@ -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
+21 -5
View File
@@ -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
+20 -5
View File
@@ -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
+18 -12
View File
@@ -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
+24 -17
View File
@@ -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),
+2
View 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."},
)
+54 -31
View File
@@ -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
+64 -21
View File
@@ -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))
]
+1 -1
View File
@@ -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]
+4 -4
View File
@@ -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:
+3 -3
View File
@@ -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],
},
)
+30 -15
View File
@@ -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,
)
+3 -3
View File
@@ -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
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+6 -3
View File
@@ -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(
+8 -40
View File
@@ -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"},
)
+20 -7
View File
@@ -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"}
)
+5 -2
View File
@@ -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]]:
+1 -1
View File
@@ -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",
)
+9 -3
View File
@@ -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"
+1 -1
View File
@@ -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
+13 -8
View File
@@ -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) :]
+12 -9
View File
@@ -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"
+10 -7
View File
@@ -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}]",
+58 -36
View File
@@ -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="*",
)
+12 -5
View File
@@ -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,
+6 -1
View File
@@ -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)
+66 -31
View File
@@ -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
View File
@@ -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
+4 -8
View File
@@ -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(
+4 -8
View File
@@ -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(
+1
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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:0010:00
make_window(14, 3), # 14:0017: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:0010:00
make_window(14, 3), # 14:0017: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,
)
))
]
)
+8 -7
View File
@@ -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:
+1 -1
View File
@@ -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(
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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 == {
+2 -2
View File
@@ -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"
+3 -3
View File
@@ -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()
+2 -2
View File
@@ -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)
+8 -5
View File
@@ -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
View File
@@ -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
+31
View File
@@ -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."""
+2 -1
View File
@@ -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
View File
@@ -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)),
]
)
+9 -9
View File
@@ -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
)
))
]
)
+3 -1
View File
@@ -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,
+2 -2
View File
@@ -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,
+4 -4
View File
@@ -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