mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
feat: deliver complete GENETIC optimization to main (#1330)
* feat: adapt configuration for multi optimization algorithms Decouple configuration from optimization algorithm parameters. Add to_[algorithm]_param() methods to the configuration that derive optimization algorithm specific parameters from the configuration. Add x-scope tags to the configuration options that describe for which specific algorithms the configuration option is for. The whole device settings are restructured. There are now general settings for the device classes with the afore mentioned to_[algorithm]_param() methods. The general device settings got their own directory `devices/settings`. By this the parameter class also does not have to be a pydantic model which can be used for future optimization/ simulations speed up. Also the parameter class for a device is now part of the device module. This better decouples and also is the natural place for parameters of a device. Besides this feature there are also fixes and improvements: * feat: extend home appliance time window settings and simulation Home appliance can now be configured for multiple runs with per-cycle allowed time windows. The number of remaining cycles to plan is determined at runtime by reading the ``cycles_completed_measurement_key`` from the measurement store. * feat: specialiced CycleTimeWindowSequence for time window sequences Sequence of time windows associated to cycles. This model specializes ``ValueTimeWindowSequence`` so that the ``value`` field of each ``ValueTimeWindow`` encodes the **cycle index** (0-based integer) the window belongs to. Typical use: an appliance that must run ``n`` times per day, each run constrained to a distinct time window. Assign ``value=0`` to windows for the first cycle, ``value=1`` for the second, and so on. Multiple windows may share the same cycle index (their allowed regions are unioned). Windows with ``value=None`` are silently ignored by all cycle-aware methods. * fix: Make test_configmigrate also regard the _ANY_SENTENIEL in key values * chore: Make devices configurations a map instead of a list This makes config paths stable regardless of declaration order and lets each device settings class build its own config path from ``self.device_id`` without needing an external index. Tests are adapted likewise. Devices configurations are automatically migrated from lists to maps. * chore: rename levelized_cost_of_storage_kwh to levelized_cost_of_storage_amt kwh This better fits in the naming scheme and also makes clear the costs are money. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> * fix: runtime config update ignored by config file Runtime settings were handed back to pydantic-settings as init settings, which rank below the config file and the environment. Any key already present in EOS.config.json or in the environment silently discarded the update, so a bulk PUT /v1/config returned 200 without applying anything, while the granular PUT /v1/config/{path} endpoint kept working. Add a dedicated runtime settings source ranked directly below the command line arguments and record granular updates there as well, so both endpoints share one store that survives re-evaluation of the settings sources. Environment variables keep precedence over the config file for all keys that were not set at runtime. Also repairs revert_settings() and update(), which passed their data through the same init settings. Closes #1303 * fix: env vars ignored on first config build ConfigEOS.__init__ passed self as first positional argument to _setup, which forwards it to pydantic_settings.BaseSettings.__init__. Its first positional parameter is _case_sensitive, so the environment source matched the upper case variable names against the lower case field names and returned nothing. Environment settings only took effect after the next configuration setup. * docs: changelog for config priority fixes * fix(config): preserve device identities and storage costs during migration * fix(measurement): restore JSON records into the existing singleton * fix(devices): preserve charge-rate typing and public import compatibility * feat(measurement): integrate typed energy quality and capacity APIs Port the locally backed-up measurement extensions to main async storage and PR #1256 device maps. Preserve runtime capacity estimates across #1305 bulk updates. Confirm JSON singleton restore defect on unchanged main and add regression. No production configuration or measurements included. Co-authored-by: Andreas <drbacke@gmx.de> * docs(measurement): describe household settings and consolidate regression coverage * docs(measurement): regenerate configuration and API contracts * test(measurement): isolate capacity database state between tests * ruff format fix * fix(measurement): restore JSON records into the existing singleton * test(measurement): assert restored timestamps before timezone conversion * test(measurement): assert restored timestamps before timezone conversion * fix: preserve imported feed-in revenue during parameter preparation Cancel GENETIC preparation when imported revenue cannot be read or contains invalid values, preserving the chosen provider instead of replacing it with demo tariffs. Keep valid positive, zero and negative amount/Wh series unchanged. Adapt the revenue-preservation regressions from PRs #1224 and #1304 to the async main API, including real timestamped imports and simulation repricing. The feature-only direct-marketing override remains outside this main fix. Co-authored-by: Christin <info@bikinibottom.capital> Co-authored-by: Normann <github@koldrack.com> * feat(devices): port slot-aware battery export and direct-use physics Port scoped device changes fromd2e2d58237. Keep PR #1256 parameter conversion structure and separate GENETIC0 devices. Validate physical flows and reprice changed simulation results. Co-authored-by: Andreas <drbacke@gmx.de> Co-authored-by: Christin <info@bikinibottom.capital> * docs(measurement): align API version with refreshed prerequisites * fix: return only completed optimization results per run * feat(pvforecast): add calibrated local Akkudoktor backend Port local PV modeling and outage calibration from feature commitsf976335,6dc58c3andfaed0fdby Andreas. Keep PVForecastAkkudoktor identity and remote default, adapt to async storage, and migrate legacy provider settings. * fix(cache): distinguish callables in the shared EMS cache Include the function object in cache keys so methods of one interpolator cannot reuse a probability as a power value. Cover both call orders, keyword arguments, cache hits and separate closures with identical qualified names. * fix(devices): constrain the physics port and validate export levels Defer inactive EV deadline fields to the optimizer port, reject nonfinite export rates, and document the hourly Optimize boundary. Verify converter IDs, rates and LCOS, separate GENETIC0 interpolation, physical boundary flows and independent GENETIC repricing. * docs(pvforecast): regenerate local backend configuration schema * docs(devices): regenerate slot-physics configuration and OpenAPI schemas * test: type dynamic Optimize regression arguments * style: wrap imported tariff test parameter import * style(pvforecast): apply CI import formatting * docs(pvforecast): refresh API version after CI formatting * fix(config): satisfy typed device conversion and migration contracts * docs(config): refresh validated configuration prerequisite schemas * fix(measurement): enforce typed capacity and sample validation * test(devices): align physics regressions with strict type checking * style(measurement): normalize imports for CI * docs(measurement): refresh typed measurement API schemas * docs(devices): refresh API version after prerequisite merge * test: make optimization dispatch timezones explicit * docs(interpolator): use portable reStructuredText markup * docs(devices): refresh API version after docstring compatibility fix * feat: complete configuration-driven GENETIC optimization and reports (#1329) * feat(devices): port slot-aware battery export and direct-use physics Port scoped device changes fromd2e2d58237. Keep PR #1256 parameter conversion structure and separate GENETIC0 devices. Validate physical flows and reprice changed simulation results. Co-authored-by: Andreas <drbacke@gmx.de> Co-authored-by: Christin <info@bikinibottom.capital> * feat(optimization): port tested terminal and tail value primitives Sourced2e2d58237. 22 primitive tests pass; integration with the optimizer, forecast horizon and API is still pending. Co-authored-by: Andreas <drbacke@gmx.de> Co-authored-by: Christin <info@bikinibottom.capital> * fix(devices): preserve charge-rate typing and public import compatibility * feat(measurement): integrate typed energy quality and capacity APIs Port the locally backed-up measurement extensions to main async storage and PR #1256 device maps. Preserve runtime capacity estimates across #1305 bulk updates. Confirm JSON singleton restore defect on unchanged main and add regression. No production configuration or measurements included. Co-authored-by: Andreas <drbacke@gmx.de> * test(integration): validate optimizer economics and document measurement settings * docs(integration): record tested checkpoint and remaining consolidation work * docs(development): define isolated PR packages and remaining porting gates * docs(integration): refresh API version after measurement reconciliation * docs(integration): record PR readiness verification results * docs(development): record publication and verification of PR 1322 * test(measurement): assert restored timestamps before timezone conversion * docs(development): record corrected PR head and CI progress * docs(integration): refresh API version after prerequisite alignment * docs(integration): define parallel packages and Optimize compatibility gates * fix: preserve imported feed-in revenue during parameter preparation Cancel GENETIC preparation when imported revenue cannot be read or contains invalid values, preserving the chosen provider instead of replacing it with demo tariffs. Keep valid positive, zero and negative amount/Wh series unchanged. Adapt the revenue-preservation regressions from PRs #1224 and #1304 to the async main API, including real timestamped imports and simulation repricing. The feature-only direct-marketing override remains outside this main fix. Co-authored-by: Christin <info@bikinibottom.capital> Co-authored-by: Normann <github@koldrack.com> * test(integration): verify tariff protection with mapped device physics * fix: return only completed optimization results per run * test(integration): verify algorithm aliases and mapped-device contracts * fix(cache): distinguish callables in the shared EMS cache Include the function object in cache keys so methods of one interpolator cannot reuse a probability as a power value. Cover both call orders, keyword arguments, cache hits and separate closures with identical qualified names. * fix(devices): constrain the physics port and validate export levels Defer inactive EV deadline fields to the optimizer port, reject nonfinite export rates, and document the hourly Optimize boundary. Verify converter IDs, rates and LCOS, separate GENETIC0 interpolation, physical boundary flows and independent GENETIC repricing. * feat(pvforecast): add calibrated local Akkudoktor backend Port local PV modeling and outage calibration from feature commitsf976335,6dc58c3andfaed0fdby Andreas. Keep PVForecastAkkudoktor identity and remote default, adapt to async storage, and migrate legacy provider settings. * docs(integration): record combined compatibility checks and green JSON PR CI * test: type dynamic Optimize regression arguments * docs(integration): record Optimize fix PR publication * docs(integration): record imported tariff protection PR * style(pvforecast): apply CI import formatting * style(integration): align combined regression imports * test: make optimization dispatch timezones explicit * docs(interpolator): use portable reStructuredText markup * chore: validate combined integration with locked mypy * docs: hand off six validated pull requests for manual review * feat: report genetic interval and terminal value diagnostics * feat(devices): reconcile flexible profiles and EV deadlines with cycle scheduling Adapt the flexible consumer primitives fromd2e2d582while retaining the keyed settings and per-cycle scheduling introduced by #1256. Preserve slot battery physics and GENETIC0 flat-load conversion. Cover energy conservation, deadlines, window intersections, DST, completed cycles and EV converters. * test: satisfy typed genetic PDF chart contracts * feat(optimization): resolve quarter-hour GENETIC requests from configuration * test(genetic): verify real device scheduling, measurement and export contracts Register appliance completed-cycle measurement keys so the real store accepts both default and custom counters. Exercise complete low-budget optimizer runs, persisted measurements, generic solution output and instructions, including zero-power phases, EV departure boundaries, per-cycle windows and LCOS. * fix: bound genetic report forecasts to executable horizon * feat: complete native genetic scheduling and retained result contracts * fix: retain missing raw samples when dropna is disabled * fix: align local optimization slots and measurement instants * docs: explain complete genetic rollout and PR dependencies * feat: expose retained GENETIC report through the versioned API * docs: regenerate complete genetic configuration and API schema * docs: format consolidation and review handoff markdown * test: align isolated EMS fixture with native genetic run options * test(genetic): clean up singleton measurements after device integration tests * test: freeze the clock without replacing timestamp conversion * fix: preserve explicit warmstart timezones in runtime requests * test(genetic): validate device schedules in UTC and Berlin Use explicit Berlin origins for Berlin wall-clock windows, compare absolute deadline instants correctly, and run all real device optimizer scenarios under both UTC and Europe/Berlin. Compare exported starts in the run timezone instead of assuming the output timezone matches the host. * fix: start automatic genetic runs in the site timezone * Preserve aware GENETIC snapshot times across host timezones * docs: specify site clock and rehearsed merge resolutions * test: isolate invalid measurement records and refresh API version * fix: render single-slot genetic tail diagnostics * docs: refresh schema version after report fix * fix: preserve configuration-only Optimize API contract * docs: refresh configuration request schema --------- Co-authored-by: Christin <info@bikinibottom.capital> Co-authored-by: Normann <github@koldrack.com> --------- Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: r0b2g1t <r0b2g1t@users.noreply.github.com> Co-authored-by: Normann <github@koldrack.com> Co-authored-by: Christin <info@bikinibottom.capital>
This commit is contained in:
co-authored by
Andreas
Christin
Normann
Bobby Noelte
r0b2g1t
parent
3c862543a1
commit
04f28997ea
@@ -63,7 +63,9 @@ config path from ``self.device_id`` without needing an external index.
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null
|
||||
}
|
||||
},
|
||||
"max_batteries": 1,
|
||||
@@ -98,7 +100,9 @@ config path from ``self.device_id`` without needing an external index.
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null
|
||||
}
|
||||
},
|
||||
"max_electric_vehicles": 1,
|
||||
@@ -112,7 +116,14 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"num_cycles": 1,
|
||||
"cycle_time_windows": null,
|
||||
"min_cycle_gap_h": 0,
|
||||
"cycles_completed_measurement_key": null
|
||||
"cycles_completed_measurement_key": null,
|
||||
"load_profile_power_w": null,
|
||||
"load_profile_interval_seconds": null,
|
||||
"schedule_mode": "ONCE",
|
||||
"time_windows": null,
|
||||
"earliest_start_datetime": null,
|
||||
"deadline_datetime": null,
|
||||
"deadline_policy": "BEST_EFFORT"
|
||||
}
|
||||
},
|
||||
"max_home_appliances": 3
|
||||
@@ -161,6 +172,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
0.75,
|
||||
1.0
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null,
|
||||
"measurement_key_soc_factor": "bat0-soc-factor",
|
||||
"measurement_key_power_l1_w": "bat0-power-l1-w",
|
||||
"measurement_key_power_l2_w": "bat0-power-l2-w",
|
||||
@@ -208,6 +221,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
0.75,
|
||||
1.0
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null,
|
||||
"measurement_key_soc_factor": "ev0-soc-factor",
|
||||
"measurement_key_power_l1_w": "ev0-power-l1-w",
|
||||
"measurement_key_power_l2_w": "ev0-power-l2-w",
|
||||
@@ -234,8 +249,17 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"cycle_time_windows": null,
|
||||
"min_cycle_gap_h": 0,
|
||||
"cycles_completed_measurement_key": null,
|
||||
"load_profile_power_w": null,
|
||||
"load_profile_interval_seconds": null,
|
||||
"schedule_mode": "ONCE",
|
||||
"time_windows": null,
|
||||
"earliest_start_datetime": null,
|
||||
"deadline_datetime": null,
|
||||
"deadline_policy": "BEST_EFFORT",
|
||||
"effective_num_cycles": 1,
|
||||
"measurement_keys": []
|
||||
"measurement_keys": [
|
||||
"dishwasher.cycles_completed"
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_home_appliances": 3,
|
||||
@@ -249,7 +273,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"ev0-power-l1-w",
|
||||
"ev0-power-l2-w",
|
||||
"ev0-power-l3-w",
|
||||
"ev0-power-3-phase-sym-w"
|
||||
"ev0-power-3-phase-sym-w",
|
||||
"dishwasher.cycles_completed"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null
|
||||
}
|
||||
},
|
||||
"max_batteries": 1,
|
||||
@@ -102,7 +104,9 @@
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null
|
||||
}
|
||||
},
|
||||
"max_electric_vehicles": 1,
|
||||
@@ -116,7 +120,14 @@
|
||||
"num_cycles": 1,
|
||||
"cycle_time_windows": null,
|
||||
"min_cycle_gap_h": 0,
|
||||
"cycles_completed_measurement_key": null
|
||||
"cycles_completed_measurement_key": null,
|
||||
"load_profile_power_w": null,
|
||||
"load_profile_interval_seconds": null,
|
||||
"schedule_mode": "ONCE",
|
||||
"time_windows": null,
|
||||
"earliest_start_datetime": null,
|
||||
"deadline_datetime": null,
|
||||
"deadline_policy": "BEST_EFFORT"
|
||||
}
|
||||
},
|
||||
"max_home_appliances": 3
|
||||
@@ -172,6 +183,7 @@
|
||||
"mode": "OPTIMIZATION"
|
||||
},
|
||||
"feedintariff": {
|
||||
"direct_marketing_enabled": false,
|
||||
"provider": "FeedInTariffFixed",
|
||||
"feedintarifffixed": {
|
||||
"feed_in_tariff_amt_kwh": {
|
||||
@@ -245,6 +257,11 @@
|
||||
"individuals": 400,
|
||||
"generations": 400,
|
||||
"seed": null,
|
||||
"measurement_max_age_seconds": 300,
|
||||
"tail_horizon_hours": 48,
|
||||
"terminal_value_mode": "AUTO",
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
"terminal_value_window_hours": 24,
|
||||
"penalties": {
|
||||
"ev_soc_miss": 10
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| direct_marketing_enabled | `EOS_FEEDINTARIFF__DIRECT_MARKETING_ENABLED` | `bool` | `rw` | `False` | Enable export-aware GENETIC optimization. Sale revenues remain those of the configured feed-in provider or explicit forecast; purchase prices never replace them. |
|
||||
| dvhubonline | `EOS_FEEDINTARIFF__DVHUBONLINE` | `FeedInTariffDvhubOnlineCommonSettings` | `rw` | `required` | DvhubOnline feed in tariff provider settings. |
|
||||
| energycharts | `EOS_FEEDINTARIFF__ENERGYCHARTS` | `FeedInTariffEnergyChartsCommonSettings` | `rw` | `required` | EnergyCharts feed in tariff provider settings. |
|
||||
| feedintarifffixed | `EOS_FEEDINTARIFF__FEEDINTARIFFFIXED` | `FeedInTariffFixedCommonSettings` | `rw` | `required` | Fixed feed in tariff provider settings. |
|
||||
@@ -25,6 +26,7 @@
|
||||
```json
|
||||
{
|
||||
"feedintariff": {
|
||||
"direct_marketing_enabled": false,
|
||||
"provider": "FeedInTariffFixed",
|
||||
"feedintarifffixed": {
|
||||
"feed_in_tariff_amt_kwh": {
|
||||
@@ -56,6 +58,7 @@
|
||||
```json
|
||||
{
|
||||
"feedintariff": {
|
||||
"direct_marketing_enabled": false,
|
||||
"provider": "FeedInTariffFixed",
|
||||
"feedintarifffixed": {
|
||||
"feed_in_tariff_amt_kwh": {
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
"individuals": 400,
|
||||
"generations": 400,
|
||||
"seed": null,
|
||||
"measurement_max_age_seconds": 300,
|
||||
"tail_horizon_hours": 48,
|
||||
"terminal_value_mode": "AUTO",
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
"terminal_value_window_hours": 24,
|
||||
"penalties": {
|
||||
"ev_soc_miss": 10
|
||||
}
|
||||
@@ -63,6 +68,11 @@
|
||||
"individuals": 400,
|
||||
"generations": 400,
|
||||
"seed": null,
|
||||
"measurement_max_age_seconds": 300,
|
||||
"tail_horizon_hours": 48,
|
||||
"terminal_value_mode": "AUTO",
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
"terminal_value_window_hours": 24,
|
||||
"penalties": {
|
||||
"ev_soc_miss": 10
|
||||
},
|
||||
@@ -167,9 +177,14 @@
|
||||
| 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 | `Optional[int]` | `rw` | `300` | Number of individuals (solutions) in the population [>= 10]. Defaults to 300. |
|
||||
| interval_sec | `int` | `rw` | `3600` | The optimization interval [sec]. Defaults to 3600 seconds (1 hour) |
|
||||
| interval_sec | `Literal[900, 3600]` | `rw` | `3600` | The optimization interval [sec]. Defaults to 3600 seconds (1 hour) |
|
||||
| measurement_max_age_seconds | `int` | `rw` | `300` | Maximum age of SoC measurements for configuration-based optimization [s]. |
|
||||
| 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. |
|
||||
| tail_horizon_hours | `int` | `rw` | `48` | Forecast lookahead after the control horizon [h]. No tail commands are issued. Set 0 to disable. |
|
||||
| terminal_value_euro_per_kwh | `float` | `rw` | `0.0` | Value assigned to usable battery energy remaining at the end of the optimization horizon [EUR/kWh]. This terminal value is independent of the battery LCOS. Only used with terminal_value_mode = FIXED. Defaults to 0 EUR/kWh. |
|
||||
| terminal_value_mode | `<enum 'TerminalValueMode'>` | `rw` | `AUTO` | How to value the energy left in the battery at the end of the control horizon. AUTO solves the forecast tail with an AUTO continuation proxy at its end (or only the proxy if tail is zero); FIXED uses 'terminal_value_euro_per_kwh'. Defaults to AUTO. |
|
||||
| terminal_value_window_hours | `int` | `rw` | `24` | Length of the trailing window at the effective tail end the AUTO continuation curve is derived from [h]. One day covers a full load and PV cycle. Defaults to 24 hours. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -187,6 +202,11 @@
|
||||
"individuals": 300,
|
||||
"generations": 400,
|
||||
"seed": null,
|
||||
"measurement_max_age_seconds": 300,
|
||||
"tail_horizon_hours": 48,
|
||||
"terminal_value_mode": "AUTO",
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
"terminal_value_window_hours": 24,
|
||||
"penalties": {
|
||||
"ev_soc_miss": 10
|
||||
}
|
||||
@@ -210,6 +230,11 @@
|
||||
"individuals": 300,
|
||||
"generations": 400,
|
||||
"seed": null,
|
||||
"measurement_max_age_seconds": 300,
|
||||
"tail_horizon_hours": 48,
|
||||
"terminal_value_mode": "AUTO",
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
"terminal_value_window_hours": 24,
|
||||
"penalties": {
|
||||
"ev_soc_miss": 10
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Akkudoktor-EOS
|
||||
|
||||
**Version**: `v0.3.0.dev2609171651094774`
|
||||
**Version**: `v0.3.0.dev2609171762020752`
|
||||
|
||||
<!-- 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.
|
||||
@@ -750,6 +750,32 @@ Get the latest solution of the optimization.
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/energy-management/optimization/solution/GENETIC/pdf
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_energy_management_optimization_solution_genetic_pdf_get_v1_energy-management_optimization_solution_genetic_pdf_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_energy_management_optimization_solution_genetic_pdf_get_v1_energy-management_optimization_solution_genetic_pdf_get)
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Fastapi Energy Management Optimization Solution Genetic Pdf Get
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Render the retained GENETIC result without rerunning optimization.
|
||||
|
||||
Rendering runs outside the event loop. Copy the result before offloading;
|
||||
its recorded timestamp, interval and inputs own the report's time grid.
|
||||
The legacy /visualization_results.pdf route continues to serve GENETIC0.
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/energy-management/optimization/solution/{algorithm}
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
@@ -1343,6 +1369,42 @@ Merge the measurement of given key and value into EOS measurements at given date
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/optimize
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**Links**: [local](http://localhost:8503/docs#/default/fastapi_optimize_config_v1_optimize_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_optimize_config_v1_optimize_post)
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Fastapi Optimize Config
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Optimize GENETIC using configured devices and optional fresh runtime inputs.
|
||||
|
||||
Static settings belong in configuration; query overrides are rejected.
|
||||
Forecast arrays start at local
|
||||
midnight and contain Wh per configured GENETIC slot; prices are currency/Wh.
|
||||
An empty body uses configured providers and fresh measured states of charge.
|
||||
The deprecated /optimize endpoint continues to run hourly GENETIC0.
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**Request Body**:
|
||||
|
||||
- `application/json`: {
|
||||
"$ref": "#/components/schemas/ConfigOptimizationRequest"
|
||||
}
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/prediction/dataframe
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
# EOS consolidation — integration record
|
||||
|
||||
Historical integration record, updated 2026-09-16. The completed optimizer port and
|
||||
current merge instructions are in [review handoff](review-handoff.md). Pending-work
|
||||
statements below describe earlier checkpoints.
|
||||
|
||||
Current PR-ready packages, dependency order and development guidance:
|
||||
[PR workflow](pr-workflow.md). The first independent fix is ready locally on main;
|
||||
the complete feature consolidation remains open.
|
||||
|
||||
## Pinned sources
|
||||
|
||||
- Official main: `4a3724424f98b5ce814d0340ddf3b6b5f1364752` (refreshed after initial checkpoint).
|
||||
- Feature branch: `d2e2d58237339454dd8bb226f92677c5987f8b27`.
|
||||
- PR #1256: `6ebd343047b87819b57778359784a616452b5f76`, open, conflicts.
|
||||
- PR #1305: `60b77f6da2da3a0f59518873fe7fbe9a92e733f4`, open, main target. Latest added commit is formatting only.
|
||||
- PR #1224: `0b12a34c11685822d97c3bc9f56c6292774cd930`, open, feature target.
|
||||
- PR #1304: `4b4b49f29902579a8deb34999d71eb2142c0620d`, open, feature target.
|
||||
- PR #1190 merged into main: GENETIC0 retained separately.
|
||||
|
||||
Sources: [issue #1192](https://github.com/Akkudoktor-EOS/EOS/issues/1192) and linked PRs.
|
||||
Issue body, all issue comments (none), and PR metadata were retrieved from the GitHub API.
|
||||
Use `refs/remotes/origin/main`: local `refs/heads/origin/main` is ambiguous.
|
||||
|
||||
## Isolation and recovery
|
||||
|
||||
Original EOS worktree remains on the feature branch. No reset, stash or checkout there.
|
||||
Integration branch: `integration/eos-consolidation-20260916`, sibling directory
|
||||
`EOS-integration-20260916`. Unmodified main comparison worktree: `EOS-reference-20260916`.
|
||||
|
||||
Private backup: `%USERPROFILE%/.codex/backups/eos-20260916-120324`.
|
||||
102 modified/untracked files copied byte-for-byte with SHA256 verification; binary
|
||||
tracked and index patches, original status/refs, verified `repository.bundle`.
|
||||
See private `RESTORE.md` and `manifest.json` for recovery into a NEW checkout.
|
||||
Ignored runtime files stay in the original worktree; this is not an archive of its
|
||||
virtual environment, caches or ignored production datasets. Backup is private and
|
||||
may include credentials or real measurements; never stage or publish it.
|
||||
Only explicitly reviewed source/test/document paths are staged for new commits.
|
||||
|
||||
## Functional matrix
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
| Area | Evidence | Integration state |
|
||||
| --- | --- | --- |
|
||||
| Providers, SMARD, fee model | #1192 checked; main prediction modules and #1235 | Keep main implementation; provider equivalence not yet established |
|
||||
| GENETIC0 / legacy API | #1190; `core/ems.py`, `server/eos.py`, `optimization/genetic0` | Preserved; device regressions pass, full endpoint validation pending |
|
||||
| Device maps / algorithm conversion | #1256; `devices/settings`, `config/configmigrate.py` | Merged with original history; four merge conflicts resolved |
|
||||
| Runtime updates | #1305; `config/config.py`, `core/pydantic.py` | Merged with original history; configuration tests pass |
|
||||
| Stable IDs / LCOS migration | New `test_consolidation_config.py` | Four initial regression failures fixed; 55 configuration tests pass |
|
||||
| 15-minute battery/inverter physics | Feature `devices/genetic`, `prediction/interpolator.py` | Ported in c0796e1; parameter classes retained in device modules, export/slot/efficiency tests pass |
|
||||
| New GENETIC, adaptive evolution, export states | Feature `optimization/genetic/genetic.py` | Pending; current optimizer is not the complete feature optimizer |
|
||||
| Warm start alignment | Feature d2e2d58, `test_genetic_warm_start_alignment.py` | Pending |
|
||||
| Control horizon / forecast tail / terminal value | Feature a2f4ef6, `tailvalue.py`, `terminalvalue.py` | Primitives ported in f70a786; 22 tests pass. Optimizer/horizon/API integration pending |
|
||||
| EV deadlines / flexible consumers | Feature f24d9ea; #1256 cycle windows | Pending reconciliation: preserve both per-cycle windows and profile/deadline semantics |
|
||||
| Imported feed-in revenue | #1224 + #1304, 38 feature regression cases | Neither PR targets main; adapt after GENETIC preparation port, keep author credit |
|
||||
| Local calibrated PV | Feature f976335/6dc58c3/faed0fd | Pending; keep public provider ID PVForecastAkkudoktor per #1192 |
|
||||
| Algorithm-specific PDF | Main #1205 vs feature utils/visualize.py | Keep on-demand API; feature slot/tail fields pending |
|
||||
| Measurement channels/energy/quality/capacity/household | Original uncommitted source + five new tests | Ported in 7338baf to async storage and keyed devices; 127 related tests passed |
|
||||
| Request configuration learning | Original uncommitted `genetic/configrequest.py` | Secured only; pending |
|
||||
| HA-private core differences | Separate private repo | Not ported; no HA files modified, no deployment |
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
## Completed packages and tests
|
||||
|
||||
1. `d3f96c4`: merge #1256 onto pinned main. Conflicts in devices.py and three generated
|
||||
documents; preserve maps/settings and main evolution. Regenerate OpenAPI from
|
||||
merged code. Do not adopt PR build version bumps. 244 configuration/time-window
|
||||
tests + 81 device/simulation tests passed before subsequent packages.
|
||||
2. `b43c8df`: merge #1305 without conflicts. 275 config/configabc/configmigrate/pydantic
|
||||
tests passed.
|
||||
3. `e3987b2`: stable map identities, reject key/ID mismatch, preserve renamed LCOS,
|
||||
repair legacy EV charge-rate migration. 4 regression cases failed before fix;
|
||||
55 related tests passed afterwards. These are integration/PR defects, not claimed
|
||||
as unchanged-main defects.
|
||||
|
||||
Tests currently use the existing Python 3.11.9 environment read-only; pytest adds
|
||||
this worktree's src. No mutation of the original venv. Missing pypdf 6.19.0 installed into the private
|
||||
backup test-packages directory and exposed only through test-process PYTHONPATH.
|
||||
This is not yet validation against every pinned dependency / CI Python version.
|
||||
Synthetic pytest fixtures; no real devices, server deployment or production config.
|
||||
|
||||
## HA handoff and acceptance
|
||||
|
||||
Do not relocate active feature development to this integration branch yet.
|
||||
Device collections are keyed maps, with identical map key and device_id. New settings
|
||||
live in devices/settings; conversion methods derive algorithm parameters. LCOS
|
||||
configuration is `levelized_cost_of_storage_amt_kwh`; old files migrate.
|
||||
Runtime config bulk/granular precedence follows #1305. Both algorithms remain
|
||||
separate; `/optimize` uses GENETIC0 and cannot demonstrate the new GENETIC port.
|
||||
Pin a final tested commit for HA only after all remaining packages and end-to-end
|
||||
API/forecast/output tests. No public push, PR, comment or main update is authorized.
|
||||
|
||||
1. `c0796e1`: slot-duration-aware battery/inverter simulation, bounded per-slot
|
||||
discharge/export, probabilistic direct-use energy model and GENETIC converter
|
||||
support for LCOS/export levels. Original author co-authorship retained.
|
||||
Fixed monetary goldens from the previous model are intentionally replaced with
|
||||
independent grid-flow repricing. Original simulation test passed on unchanged
|
||||
main; the changed result is not labelled a baseline failure.
|
||||
2. `f70a786`: bounded forecast reader, concave terminal value and deterministic
|
||||
forecast-tail primitives. 22 direct primitive tests passed. Does NOT activate
|
||||
the new optimization algorithm, warmstart, horizon handling or diagnostic API.
|
||||
3. `d9c434f`: keep main's charge-rate ndarray contract and exported constant after
|
||||
`#1256`. The first broad collection found this missed overlap.
|
||||
4. `7338baf`: typed measurement channels, sample quality, energy integration,
|
||||
household accounting and battery capacity estimation. Adapt all storage calls
|
||||
and endpoints to main's async API; map battery IDs through device collections.
|
||||
Capacity estimates survive subsequent runtime config updates and never change
|
||||
the active capacity_wh. 127 new/existing measurement tests passed, including
|
||||
JSON/SQLite/LMDB restart, partial coverage and real FastAPI routes with no lifespan.
|
||||
JSON measurement reload regression reproduced on unchanged afb7bcb (0 records
|
||||
after reload); applying the previously local singleton fix resolves it.
|
||||
5. `876756b`: replace stochastic GENETIC output equality with schema, independent
|
||||
per-slot cost/revenue accounting and physical-range assertions. GENETIC0 goldens
|
||||
remain unchanged. Short optimizer/PDF runs: 4 GENETIC0 and 4 GENETIC pass; one
|
||||
400-generation case for each algorithm skipped by the existing --finalize rule.
|
||||
New household configuration field has documentation metadata.
|
||||
|
||||
### Remaining integration risks and concrete next package
|
||||
|
||||
The current GENETIC orchestration is still the main-era optimizer with the newly
|
||||
ported device physics. It must NOT be described as the complete feature optimizer.
|
||||
Next port `optimization/genetic/genetic.py`, its parameter preparation and solution
|
||||
model together, carrying feature d2e2d58 warmstart and #1304 revenue fixes. Translate
|
||||
old top-level optimization settings to `optimization.genetic`. Check the actual
|
||||
prediction record units: current `elecprice_marketprice_wh` and `feed_in_tariff_wh`
|
||||
arrays already contain amount/Wh; only amount/kWh configuration is divided by 1000.
|
||||
Do not apply a second conversion to the existing *_wh arrays. Use #1256 `to_genetic_*` converters
|
||||
instead of reintroducing parallel settings. Reconcile flexible profiles/deadlines
|
||||
with #1256 per-cycle windows/completed cycles before exposing the combined API.
|
||||
Both porting sides must retain their regression cases. Follow with local PV and
|
||||
algorithm-specific on-demand PDF; do not copy feature's synchronous/automatic-PDF
|
||||
server paths over main. Then adapt local configrequest.py to the current API.
|
||||
|
||||
No feature-provider robustness fixes are claimed ported merely because a provider
|
||||
with the same name exists on main. HA private-core differences remain unaudited.
|
||||
No full 2030-test suite run, --finalize optimization run, dependency-pin CI matrix,
|
||||
or physical installation validation has been completed.
|
||||
|
||||
### Backup verification
|
||||
|
||||
An independent clone of repository.bundle at the original feature HEAD was restored
|
||||
using the backup files. All 102 restored SHA256 hashes match. The original worktree
|
||||
HEAD and all 102 file hashes were also rechecked unchanged after the source ports.
|
||||
|
||||
## Reproducing the focused acceptance run
|
||||
|
||||
Use a disposable environment with the repository dependencies plus pytest,
|
||||
pytest-asyncio, pytest-xprocess, pytest-cov and pypdf. The private backup contains
|
||||
`test-environment.json`, `integration-final.xml` and the complete collection log.
|
||||
Run from the integration worktree (not the original feature worktree):
|
||||
|
||||
```powershell
|
||||
python -m pytest `
|
||||
tests/test_typingmodels.py `
|
||||
tests/test_config.py `
|
||||
tests/test_configabc.py `
|
||||
tests/test_configmigrate.py `
|
||||
tests/test_configfile.py `
|
||||
tests/test_pydantic.py `
|
||||
tests/test_consolidation_config.py `
|
||||
tests/test_genetichomeappliance.py `
|
||||
tests/test_genetic0battery.py `
|
||||
tests/test_genetic0inverterefficiency.py `
|
||||
tests/test_genetic0simulation.py `
|
||||
tests/test_battery.py `
|
||||
tests/test_inverter.py `
|
||||
tests/test_inverter_efficiency.py `
|
||||
tests/test_geneticsimulation.py `
|
||||
tests/test_geneticsimulation2.py `
|
||||
tests/test_terminalvalue.py `
|
||||
tests/test_tailvalue_physics.py `
|
||||
tests/test_interpolator.py `
|
||||
tests/test_measurement_channels.py `
|
||||
tests/test_measurement_energy.py `
|
||||
tests/test_measurement_household.py `
|
||||
tests/test_battery_capacity.py `
|
||||
tests/test_measurement_file_restore.py `
|
||||
tests/test_measurement.py `
|
||||
tests/test_genetic0optimize.py `
|
||||
tests/test_geneticoptimize.py `
|
||||
tests/test_doc.py `
|
||||
-q `
|
||||
--tb=short
|
||||
```
|
||||
|
||||
Do not inherit EOS_DIR/EOS_CONFIG_DIR from documentation generation when running
|
||||
pytest: these conflict with fixture-controlled temporary config directories.
|
||||
Generate OpenAPI after committing source changes; source-dirty version timestamps
|
||||
otherwise make exact documentation comparisons nondeterministic.
|
||||
|
||||
## Final verified checkpoint for this work session
|
||||
|
||||
Focused combined run: **633 passed, 2 skipped in 65.46 s**. The skips are the
|
||||
existing 400-generation --finalize cases. Zero remaining failures in that run.
|
||||
All 2030 collected tests were collectable after supplying pypdf and resolving the
|
||||
charge-rate import overlap; collection alone is not a pass of the full suite.
|
||||
Ruff passed for the ported measurement/device/interpolator/primitive source files;
|
||||
git diff --check passed. OpenAPI/config documentation generation and the documentation
|
||||
comparison tests passed. The first combined run caught fixture state leakage in the
|
||||
new battery test; reset-before/after fixture isolation resolved it on the repeated run.
|
||||
|
||||
This is a tested partial integration checkpoint, NOT completion of the consolidation.
|
||||
The source packages are locally committed; release, full feature acceptance and
|
||||
upstream submission remain pending. Do not move active development or HA deployment
|
||||
here until the remaining optimizer/prognosis/output/request packages are integrated.
|
||||
|
||||
## PR-readiness verification, 2026-09-16
|
||||
|
||||
Current main `7ebe6d7` is incorporated. Two reviewable local packages now exist:
|
||||
`fix/measurement-json-reload` (`dba0c9c`, independently based on main) and
|
||||
`feat/measurement-energy-quality-capacity` (`ea3383e`, depends on the configuration
|
||||
integration base `d546f08`). The latter excludes the new optimizer/device physics.
|
||||
See [PR workflow](pr-workflow.md) and its concrete draft descriptions.
|
||||
|
||||
The expanded integration run completed with 764 passed, 3 skipped and 2 documentation
|
||||
failures in 453.99 seconds. Both failures were solely stale generated OpenAPI version
|
||||
metadata, not schema or functional differences. Regenerated the two files; the focused
|
||||
rerun of all 5 documentation tests passed. No remaining failure from that selection.
|
||||
The complete expanded selection was not rerun after this documentation-only correction.
|
||||
Three skips: the two existing long --finalize optimizer cases and the development-only
|
||||
Energy-Charts forecast case. All 128 active Energy-Charts regressions passed.
|
||||
|
||||
Independent package checks: JSON fix 49 tests; isolated measurement package 453 tests
|
||||
plus 5 documentation tests; capacity fixture isolation followed by 74 passing tests.
|
||||
Ruff and source formatting passed for the independent fix; measurement source Ruff
|
||||
passed. Full pinned Linux/Python 3.13 CI remains outstanding.
|
||||
|
||||
All original 102 saved file hashes and original feature HEAD were checked unchanged.
|
||||
Both PR worktrees are clean, locally committed, and unpublished. This enables small
|
||||
independent PRs now; it does not complete the still-open optimizer/PV/output port.
|
||||
|
||||
## First upstream PR published, 2026-09-16
|
||||
|
||||
Explicit user approval received for publishing the standalone JSON restore fix.
|
||||
Fetched main `4a37244` (dependency update #1321) and rebased the standalone branch;
|
||||
its new head is `bdc754d12fd08e0da18d3156642c695f4bf67bd2`. All 49 measurement tests,
|
||||
source Ruff and formatting checks passed again. Pushed only
|
||||
`fix/measurement-json-reload` and created [PR #1322](https://github.com/Akkudoktor-EOS/EOS/pull/1322)
|
||||
against main. Verified the remote head and PR patch: one commit, exactly two files.
|
||||
The other integration/measurement branches were not pushed. The PR is conflict-free,
|
||||
not merged; GitHub CI was started and is being checked. Original HEAD and all 102
|
||||
backup hashes were rechecked unchanged. Earlier notes saying all packages are
|
||||
unpublished are historical checkpoints; this section supersedes them for this fix.
|
||||
|
||||
## Parallel package preparation and compatibility
|
||||
|
||||
On the user's explicit request, independent PV and tariff ports and Optimize-mode
|
||||
compatibility coverage are being prepared in separate worktrees. The main/integration
|
||||
configuration prerequisite was refreshed to main4a37244 and PR1305head60b77f6;
|
||||
285 configuration/migration/Pydantic tests pass. The latest 1305 change only formats
|
||||
three files. See [PR integration matrix](pr-integration-matrix.md) for the seven
|
||||
remaining functional packages, existing upstream prerequisites, and combined
|
||||
Optimize acceptance gates. Parallel work is not authorization to publish every lane.
|
||||
|
||||
## Completed parallel checkpoint and first green CI
|
||||
|
||||
Source checkpoint b684748 now combines local PV, imported tariff protection,
|
||||
device physics/cache corrections and the confirmed Optimize failure-path fix.
|
||||
The combined selection passed 277 tests (3 regular skips); measurement APIs and
|
||||
persistence then passed 132 tests; regenerated documentation passed all 5 checks.
|
||||
These are targeted local checks, not a full integration CI run. The complete new
|
||||
GENETIC orchestration, config-owned request and result/PDF port remain outstanding.
|
||||
See the updated integration matrix for exact dependencies and limitations.
|
||||
|
||||
PR #1322 now has two commits through ce132ea. Its initial mypy error in a test's
|
||||
optional timestamp assertion was fixed; all current checks are green: 1,884 passed,
|
||||
16 skipped in pinned Linux/Python 3.13 pytest, plus pre-commit/mypy, CodeQL and
|
||||
Docker build. PR remains open and mergeable. Only this branch has been published;
|
||||
no merge or HA deployment occurred. The finite CI follow-up was paused after
|
||||
verification. Original feature HEAD and all 102 backed-up file hashes remain intact.
|
||||
@@ -0,0 +1,67 @@
|
||||
# EOS interface handoff to Home Assistant
|
||||
|
||||
2026-09-16 — complete EOS feature port prepared for review in eight PRs.
|
||||
|
||||
The combined branch is `feat/genetic-complete`, based on main `4a37244` and the
|
||||
prerequisite PRs. Use [review handoff](review-handoff.md) for merge order and current
|
||||
PR links, and [GENETIC rollout](genetic-rollout.md) for configuration and manual
|
||||
acceptance. Pin the final merged EOS commit after CI and installation acceptance;
|
||||
the earlier `integration/eos-consolidation-20260916` is a historical checkpoint.
|
||||
|
||||
- Configuration collections `devices.batteries`, `electric_vehicles`, `inverters`,
|
||||
`home_appliances` are maps keyed by stable device ID. A supplied `device_id` must
|
||||
match its map key; omitted IDs use that key. Old lists migrate. LCOS configuration
|
||||
uses `levelized_cost_of_storage_amt_kwh`; old files retain their values.
|
||||
- Algorithm settings are separate under `optimization.genetic` / `genetic0`.
|
||||
Existing main configuration uses `interval_sec` and `horizon_hours` there.
|
||||
Do not send feature-branch top-level interval/horizon settings without migration.
|
||||
- `#1305` runtime bulk/granular changes are retained above file/environment sources;
|
||||
config persistence is still an explicit existing save operation.
|
||||
- New measurement routes: PUT/GET `/v1/measurement/samples`, GET
|
||||
`/v1/measurement/energy`, GET `/v1/measurement/household`, POST
|
||||
`/v1/measurement/battery-capacity/{battery_id}`. OpenAPI describes request schemas.
|
||||
Sample times require timezone; values remain in declared raw units, derived energy
|
||||
is Wh and includes coverage/quality. Missing data is not implicitly zero energy.
|
||||
- Capacity estimates are separate evidence; even store_estimate=true never replaces
|
||||
devices.batteries[id].capacity_wh. Explicitly stored estimates survive runtime
|
||||
bulk changes; existing config save controls disk persistence.
|
||||
- Python measurement storage-facing methods are async and must be awaited. The HTTP
|
||||
schema remains ordinary JSON; HA does not need to mirror EOS internals.
|
||||
- Legacy POST `/optimize` remains GENETIC0. New POST `/v1/optimize` runs GENETIC
|
||||
from the typed EOS device configuration. Its body accepts runtime `soc`,
|
||||
`forecasts`, `start_solution` and `start_solution_datetime`; hardware overrides
|
||||
and query-string overrides are rejected. Request SoC is an integer percentage
|
||||
keyed by device ID; automatic
|
||||
measurement lookup uses recent `<device_id>-soc-factor` values from 0 to 1.
|
||||
Missing, stale, future or invalid observations fail instead of implying zero.
|
||||
- GENETIC supports 900/3600-second slots. Provider power in W is converted once to
|
||||
Wh per slot; runtime forecast arrays are already Wh per slot and prices per Wh.
|
||||
Runtime arrays begin at local midnight. Missing control forecasts fail the run;
|
||||
shorter forecast tails are clipped with diagnostics. Explicit/imported sale
|
||||
prices, including zero and negative values, remain authoritative.
|
||||
- The new optimizer includes timestamp-aligned warmstarts, opt-in battery export,
|
||||
EV deadlines, flexible consumer profiles and per-cycle windows/completed cycles.
|
||||
AUTO/FIXED terminal value and forecast-tail diagnostics affect scoring, never
|
||||
extend the executable control horizon, and remain separate from battery wear.
|
||||
- Automatic GENETIC runs use the site's timezone derived from its coordinates;
|
||||
explicit run starts and warmstart timestamps retain their timezone/instant.
|
||||
Configure device IDs and schedules against this timezone, including DST.
|
||||
- GET `/v1/energy-management/optimization/solution/{algorithm}` returns the stored
|
||||
algorithm-specific result. Native/generic results and execution plans publish
|
||||
atomically for the successful run. Failed Optimize calls return errors rather
|
||||
than a previous successful result.
|
||||
- GET `/v1/energy-management/optimization/solution/GENETIC/pdf` renders the stored
|
||||
GENETIC snapshot on demand (404 before a result exists). The legacy PDF endpoint
|
||||
and GENETIC0 implementation remain separate. The local calibrated Akkudoktor PV
|
||||
backend and the measurement APIs are included in the prerequisite PRs.
|
||||
|
||||
No HA repository changes, lab deployment, real device control or production config
|
||||
were performed. Retire the private HA core only after its differences are audited
|
||||
and a final EOS commit passes the full feature/API acceptance scenarios.
|
||||
|
||||
GENETIC currently supports one inverter, one stationary battery, one EV and multiple
|
||||
household appliances. Unsupported device counts or inconsistent inverter/battery
|
||||
links are rejected. Verify both 15- and 60-minute Optimize runs, fresh measurements,
|
||||
tariff units, result/plan timestamps and reports in the actual HA installation.
|
||||
EOS code and synthetic scenarios are automated-test coverage; they do not validate
|
||||
HA entities, physical hardware or the remaining private HA-core differences.
|
||||
@@ -0,0 +1,93 @@
|
||||
# GENETIC configuration and manual acceptance
|
||||
|
||||
Apply the dependencies in [review handoff](review-handoff.md) before the complete
|
||||
GENETIC PR. These changes implement the feature branch on current EOS interfaces;
|
||||
they do not migrate a running Home Assistant installation automatically.
|
||||
|
||||
## Configuration
|
||||
|
||||
Hardware and schedules belong in the typed `devices` maps, keyed by stable device
|
||||
IDs. Configure an inverter and link its `battery_id` to the configured stationary
|
||||
battery, or leave it empty when there is no battery. GENETIC currently supports one
|
||||
inverter, one stationary battery, one EV and multiple household appliances. Explicit
|
||||
validation rejects unsupported device counts and inconsistent links.
|
||||
|
||||
Example optimization settings (merge into an otherwise complete configuration):
|
||||
|
||||
```json
|
||||
{
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"genetic": {
|
||||
"interval_sec": 900,
|
||||
"horizon_hours": 24,
|
||||
"tail_horizon_hours": 48,
|
||||
"terminal_value_mode": "AUTO",
|
||||
"measurement_max_age_seconds": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `interval_sec: 3600` for hourly operation. AUTO evaluates available forecast
|
||||
continuation; FIXED uses `terminal_value_euro_per_kwh`. Battery wear/LCOS remains a
|
||||
separate cost. The tail affects scoring, never the emitted control horizon.
|
||||
`prediction.hours` must cover the control horizon; shorter available tails are
|
||||
clipped with diagnostics. Missing control forecasts cancel the run.
|
||||
|
||||
Old feature settings such as `optimization.interval` and flat terminal-value fields
|
||||
migrate into `optimization.genetic`; explicit nested settings win. Check the saved
|
||||
configuration after migration. GENETIC0 retains its separate settings and `/optimize` API.
|
||||
|
||||
Automatic GENETIC runs use the site's timezone derived from its coordinates, even
|
||||
when the server runs in UTC. An explicitly supplied run start retains its timezone.
|
||||
Stored results and warmstarts retain explicit timezones and absolute instants.
|
||||
|
||||
## Requests and measurements
|
||||
|
||||
`POST /v1/optimize` runs GENETIC from configuration. An empty JSON object uses the
|
||||
configured forecasts and fresh measurements. Runtime input may contain `soc`,
|
||||
`forecasts`, `start_solution` and `start_solution_datetime`. Hardware overrides in
|
||||
this body and query-string overrides are rejected. For example, with a configured
|
||||
device ID `storage`:
|
||||
|
||||
```json
|
||||
{"soc": {"storage": 42}}
|
||||
```
|
||||
|
||||
Request SoC values are integer percentages. Without an override, the measurement
|
||||
key `<device_id>-soc-factor` must contain a recent factor from 0 to 1. Invalid,
|
||||
future or stale measurements never become an assumed zero SoC. Completed appliance
|
||||
cycles use their configured measurement key, default `<device_id>.cycles_completed`.
|
||||
|
||||
Optional forecast arrays begin at local midnight, with one value per configured
|
||||
slot: `pv_forecast_wh` and `total_load` are Wh per slot; `electricity_price_per_wh`
|
||||
and `feed_in_tariff_per_wh` are currency per Wh. Provider PV/load power in W is
|
||||
converted by the resolver. Supplied/imported sale tariffs are never replaced by
|
||||
purchase prices. Missing optional arrays come from the selected providers.
|
||||
|
||||
Successful requests return the native result for that run. A failed run returns an
|
||||
error and cannot masquerade as a cached success. Generic solutions, plans and reports
|
||||
are derived from the same stored result. Warmstarts retain their source time so a
|
||||
later run shifts the previous controls to the new slot origin.
|
||||
|
||||
Retrieve the new GENETIC report with
|
||||
`GET /v1/energy-management/optimization/solution/GENETIC/pdf`. It renders the stored
|
||||
snapshot on demand; a missing result returns 404. The legacy PDF URL remains unchanged.
|
||||
|
||||
## Manual acceptance after merge
|
||||
|
||||
1. Back up the running configuration and update all merged EOS packages together.
|
||||
2. Confirm device IDs, inverter linkage, capacities, efficiencies, import/export
|
||||
limits, tariff units and the battery-export setting against your actual hardware.
|
||||
3. Check fresh SoC measurements and forecast coverage, then run Optimize at 60 and
|
||||
15 minutes. Inspect timestamps, slot energies, native/generic results and the PDF.
|
||||
4. Compare AUTO and FIXED terminal value with identical inputs. Tail diagnostics
|
||||
must not appear as extra commands; sale prices must retain their configured signs.
|
||||
5. Exercise an EV departure and a flexible consumer schedule, including a completed
|
||||
cycle. Check permitted windows, required energy and device commands in your setup.
|
||||
6. Make a measurement stale or omit control forecast coverage and confirm the request
|
||||
fails explicitly. Verify the legacy GENETIC0 endpoint if your installation uses it.
|
||||
|
||||
Automated checks cover modeled behavior. The final manual test validates the actual
|
||||
HA entities, provider data and physical installation.
|
||||
@@ -0,0 +1,25 @@
|
||||
# fix(optimization): reject invalid imported feed-in tariffs without fallback
|
||||
|
||||
Published with user approval as [PR #1324](https://github.com/Akkudoktor-EOS/EOS/pull/1324).
|
||||
Branch `fix/imported-feedin-main`, head `2a3b961`, base main `4a37244`.
|
||||
Two commits, two changed files. Original contributions from #1224/#1304 are credited.
|
||||
|
||||
GENETIC preparation now cancels if FeedInTariffImport cannot supply a finite
|
||||
one-dimensional tariff array matching the forecast length. It keeps the configured
|
||||
provider instead of switching to demo tariffs. Positive, zero and negative valid
|
||||
amount/Wh revenues remain unchanged. Other providers keep their fallback behavior.
|
||||
|
||||
Publication check: 68 passed, 2 regular long-running tests skipped; source Ruff,
|
||||
format and diff checks passed. Scoped mypy passed for the two changed files with
|
||||
transitive imports/untyped dependency diagnostics excluded. Full pinned Linux CI: 1921 passed, 16 skipped;
|
||||
pre-commit including full mypy, CodeQL and Docker passed.
|
||||
Regression coverage includes seven provider identities, simulation arithmetic and
|
||||
actual timestamped imports. Eleven invalid-input regressions fail on unchanged main.
|
||||
|
||||
The existing forward-fill path is unchanged: validation of its resulting array
|
||||
does not establish raw timestamp coverage or freshness. Feature direct-marketing
|
||||
overrides and quarter-hour orchestration remain part of the later GENETIC port.
|
||||
Legacy /optimize and GENETIC0 are unchanged. The source fix is already integrated
|
||||
with PV, device/configuration and Optimize-result packages in local combined tests.
|
||||
|
||||
No merge or deployment. All current-head checks completed successfully.
|
||||
@@ -0,0 +1,38 @@
|
||||
# feat(measurement): add typed energy, quality and capacity APIs
|
||||
|
||||
Local branch: `feat/measurement-energy-quality-capacity`.
|
||||
Review base: `feat/config-integration-base` (`9038b65`).
|
||||
Eventual target: official main, after its configuration prerequisites land.
|
||||
Status: published as [PR #1326](https://github.com/Akkudoktor-EOS/EOS/pull/1326), head `635ff2d`, against the
|
||||
prerequisite branch.
|
||||
|
||||
## Proposed PR body
|
||||
|
||||
Add typed measurement channels with explicit units and timestamp semantics, sample
|
||||
quality, energy integration, household energy balances and battery capacity estimates.
|
||||
Adapt persistence and REST access to main's asynchronous storage. Battery estimates
|
||||
use keyed device identities and remain separate from the configured active capacity;
|
||||
storing an estimate requires an explicit request and preserves runtime config updates.
|
||||
|
||||
Expose `/v1/measurement/samples`, `/v1/measurement/energy`,
|
||||
`/v1/measurement/household` and `/v1/measurement/battery-capacity/{battery_id}`.
|
||||
Regenerate the configuration and OpenAPI contracts. Use synthetic data only.
|
||||
|
||||
Validation: 453 configuration/measurement/device simulation tests and 5 documentation
|
||||
tests pass. A further 74 measurement/capacity tests pass after carrying over fixture
|
||||
isolation. Ruff passes for measurement source and the measurement REST module.
|
||||
Final pinned Linux/Python 3.13 CI: 2046 passed, 16 skipped; pre-commit with full mypy and Docker passed.
|
||||
CodeQL becomes applicable after retargeting to main.
|
||||
|
||||
Depends on the device maps/converters in #1256, runtime configuration in #1305 and
|
||||
local fixes for stable device IDs, LCOS migration and charge-rate compatibility.
|
||||
Includes the independently prepared JSON restore fix; it should land separately first.
|
||||
No new GENETIC orchestration or battery/inverter physics is included in this branch.
|
||||
|
||||
## Submission gate
|
||||
|
||||
Do not open this whole branch against main now: its ancestry still includes the
|
||||
unmerged configuration PRs. Preserve those contributors' existing PRs and credit.
|
||||
Once prerequisites are merged, rebuild/rebase the measurement package onto that
|
||||
main, inspect the resulting diff and rerun combined tests and CodeQL before merging.
|
||||
The comparison to `feat/config-integration-base` isolates today's measurement work.
|
||||
@@ -0,0 +1,41 @@
|
||||
# fix(measurement): restore JSON records into the existing singleton
|
||||
|
||||
Target: `Akkudoktor-EOS/EOS:main`.
|
||||
Local branch: `fix/measurement-json-reload`.
|
||||
Local head: `ce132ea` (based on main `4a37244`).
|
||||
Status: published with explicit user approval as [PR #1322](https://github.com/Akkudoktor-EOS/EOS/pull/1322).
|
||||
Two commits, two files; published head and diff verified. Not merged.
|
||||
The second commit adds an explicit non-null timestamp assertion after CI mypy
|
||||
flagged the test. All 49 local tests still pass. Pre-commit (including mypy),
|
||||
CodeQL and Docker build passed on ce132ea. Full pinned Linux/Python 3.13 CI pytest
|
||||
also passed: 1,884 passed, 16 skipped, including --finalize and config side-effect
|
||||
checks (run 35118801197). PR remains open and mergeable; no merge performed.
|
||||
|
||||
## Proposed PR body
|
||||
|
||||
When measurement persistence falls back to JSON, loading a saved file reports success
|
||||
but does not restore its records: validating a second `Measurement` returns the
|
||||
existing singleton. Parse and validate the individual records before inserting them
|
||||
into that singleton. Invalid files now return `False` instead of reporting success.
|
||||
|
||||
Regression coverage checks round trips, timestamp preservation, repeated loading,
|
||||
merging with existing timestamps, malformed files without partial validation writes,
|
||||
and database-provider precedence. Four regression cases fail on unchanged main.
|
||||
With the fix, all 49 measurement tests pass.
|
||||
|
||||
Validation: `python -m pytest tests/test_measurement_file_restore.py tests/test_measurement.py -q`;
|
||||
Ruff check and format check of the changed source; `git diff --check`.
|
||||
Run locally on Windows/Python 3.11.9, followed by successful pinned Linux/Python 3.13
|
||||
CI and its complete test suite as recorded above.
|
||||
|
||||
No settings or API schema changes. This fix is independent of #1256, #1305 and the
|
||||
GENETIC port.
|
||||
|
||||
## Exact review scope
|
||||
|
||||
- `src/akkudoktoreos/measurement/measurement.py`
|
||||
- `tests/test_measurement_file_restore.py`
|
||||
|
||||
Only these two files differ from the pinned main. Do not publish the integration
|
||||
branch as part of this PR. Publication approval was received on 2026-09-16. Only this named branch was pushed.
|
||||
Inspect CI and review before any merge; merging was not part of this publication request.
|
||||
@@ -0,0 +1,24 @@
|
||||
# fix(optimization): return only the current completed Optimize result
|
||||
|
||||
Published with user approval as [PR #1323](https://github.com/Akkudoktor-EOS/EOS/pull/1323).
|
||||
Branch: `fix/optimize-run-result`; base main `4a37244`; head `ef8d913`.
|
||||
Three commits, three changed files. No merge or deployment.
|
||||
|
||||
A failed explicit optimization previously returned an old cached solution as HTTP
|
||||
200. Conversion failures could publish a new native result alongside the previous
|
||||
generic result and plan. Return this run's completed solution directly to the route;
|
||||
build all three representations before publishing them together. Preserve previous
|
||||
consistent results after optimizer/conversion errors and skip the failed run's
|
||||
control dispatch. Legacy `/optimize` remains GENETIC0; automatic mode keeps its
|
||||
configured algorithm selection. No optimizer mathematics or slot behavior changes.
|
||||
|
||||
Validation: 38 tests passed, 2 regular long-running tests skipped. All 22 new
|
||||
regression cases passed again after typing dynamic test keyword arguments. Source
|
||||
Ruff, formatting and diff checks passed. Scoped mypy passed for all three changed
|
||||
files (transitive imports/untyped dependency diagnostics excluded locally).
|
||||
Pinned Linux CI: 1908 passed, 16 skipped; pre-commit including full mypy, CodeQL and Docker passed. Thirty
|
||||
regressions also passed separately in UTC and Europe/Berlin; timezone expectations are now explicit.
|
||||
|
||||
The fix is already combined with PV, tariff and device packages in the integration
|
||||
branch. Integration also carries the test annotation correction. Independent of
|
||||
`#1322`, #1256 and #1305. Original working copy remains untouched.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Remaining EOS packages and Optimize compatibility
|
||||
|
||||
Historical planning snapshot. The remaining packages below are now implemented;
|
||||
see [review handoff](review-handoff.md) for current dependencies and acceptance.
|
||||
|
||||
Snapshot: 2026-09-16. Official main: `4a37244`. Feature source: `d2e2d58`, plus
|
||||
separately backed-up local work. This is a functional package estimate, not a claim
|
||||
that every differing commit requires its own PR.
|
||||
|
||||
## What is still missing on main
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
| Package | Missing behavior relative to feature/local work | Local state | Dependency |
|
||||
| --- | --- | --- | --- |
|
||||
| Device physics | Slot-duration-aware battery/inverter flows, export control, efficiency and limits | Published as #1327 against the configuration prerequisite branch; combined in integration | #1256 device settings/converters |
|
||||
| Complete GENETIC | Quarter-hour orchestration, adaptive evolution, export states, warmstart alignment, forecast tail/terminal value, EV deadlines and flexible consumer profiles | Physics and primitives exist; orchestration/parameter/output integration remains open | Device physics, #1256, tariff contract |
|
||||
| Imported tariff protection | Preserve supplied/imported revenue; avoid silent demo or market-price replacement | Published as #1324 and combined in integration; feature-specific override still required inside GENETIC port | Main patch independent; second part belongs with GENETIC |
|
||||
| Local calibrated PV | Local Akkudoktor PV calculation, measurement calibration and outage handling under existing provider ID | Published as #1325 against main; combined in integration | Provider-specific settings; combined forecast/optimizer acceptance later |
|
||||
| Measurement APIs | Typed channels, quality, energy integration, household balance and capacity estimate APIs | Published as #1326 against the configuration prerequisite branch | #1256, #1305, configuration corrections; JSON fix #1322 |
|
||||
| Config-owned Optimize request | Local ConfigOptimizationRequest, /v1/optimize, runtime observations and common parameter resolver | Backed up; async/maps/converters adaptation pending | New GENETIC and #1305 |
|
||||
| Result/PDF output | Quarter-hour, flexible-consumer, export, tail/rest-value diagnostics in main's on-demand algorithm-specific output | Pending | Final GENETIC result contract |
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Four of the seven feature packages are now published as #1324–#1327. Three broader
|
||||
packages remain unported: complete GENETIC, the config-owned Optimize API, and result/PDF
|
||||
output. Their final PR split depends on the resulting contracts. #1256 and #1305 are existing
|
||||
foundation PRs, not two newly invented replacements. #1224/#1304 already address
|
||||
parts of the tariff work against the old feature branch; preserve/reconcile those
|
||||
contributions rather than count duplicate implementations as separate deliverables.
|
||||
PR #1322 is already published and is additional to this feature table.
|
||||
|
||||
One additional standalone defect fix is published as PR #1323, fix/optimize-run-result:
|
||||
an explicit Optimize request must return only its own completed result. Cache
|
||||
method identity was also corrected as a prerequisite within the device package;
|
||||
it does not currently add another planned feature PR.
|
||||
|
||||
Local helper scripts and private HA-core divergence are not silently included in this
|
||||
count. They remain separately secured/to be audited. Changelog/release work follows
|
||||
acceptance; it is not another optimizer implementation.
|
||||
|
||||
## Parallel lanes
|
||||
|
||||
- Local PV: `feat/local-pv-main-port`, sibling worktree `EOS-pr-local-pv`.
|
||||
- Tariff preparation: `fix/imported-feedin-main`, sibling `EOS-pr-feedin-main`.
|
||||
- Device physics: `feat/slot-device-physics`, sibling `EOS-pr-device-physics`.
|
||||
- Optimize failure correction: `fix/optimize-run-result`, sibling
|
||||
`EOS-reference-optimize-main` (now a named review branch).
|
||||
- Optimize compatibility tests: `test/optimize-pr-contracts`, sibling
|
||||
`EOS-pr-optimize-contracts`. These are shared acceptance coverage, not necessarily
|
||||
an extra standalone public PR.
|
||||
- Root/integration: configuration prerequisites, shared API/algorithm contracts and
|
||||
eventual combination of the tested packages.
|
||||
|
||||
Do not have multiple lanes independently rewrite geneticparams.py, EMS.run or the
|
||||
same configuration structure. A tested tariff patch will be carried into the core
|
||||
port; the core port must keep its regression cases. PV keeps the public
|
||||
PVForecastAkkudoktor ID and existing remote behavior unless explicitly selected.
|
||||
Parallel preparation does not imply parallel unreviewed merges or publishing all
|
||||
branches. The JSON fix #1322 and subsequently the Optimize fix #1323 have publication
|
||||
approval. Tariff fix #1324 was subsequently authorized and published. The user then
|
||||
authorized all prepared packages: PV #1325, measurement #1326 and device physics
|
||||
`#1327` are also published. The latter two target the explicit configuration comparison
|
||||
branch 9038b65 pending their prerequisites. No upstream merges were performed.
|
||||
|
||||
## Three different Optimize contracts
|
||||
|
||||
1. Legacy POST `/optimize` on main explicitly runs GENETIC0, hourly. Existing payload
|
||||
and legacy response aliases must keep working regardless of configured default.
|
||||
2. Automatic EMS with `ems.mode=OPTIMIZATION` selects `optimization.algorithm`:
|
||||
GENETIC or GENETIC0. Preparation and solution conversion are asynchronous.
|
||||
PREDICTION and DISABLED must not accidentally run optimization or dispatch controls.
|
||||
3. The feature worktree's LOCAL POST `/v1/optimize` and ConfigOptimizationRequest are
|
||||
not present on main/integration yet. They must be adapted to current maps,
|
||||
converters and algorithm-specific settings. Old feature `/optimize` meant a
|
||||
different optimizer; clients need explicit migration, not a silent route switch.
|
||||
|
||||
Current blockers: GENETIC.prepare still forces interval_sec to 3600 and EMS start
|
||||
alignment floors to the hour. Fifteen-minute device tests do not prove quarter-hour
|
||||
Optimize-mode support. The core package must change preparation, slot alignment,
|
||||
optimization and response metadata together.
|
||||
|
||||
Confirmed on unchanged main 4a37244: after a failed explicit optimization, the HTTP
|
||||
route returned the previous stored solution with HTTP 200. Conversion errors could
|
||||
also publish a new native result alongside an old generic result/plan. The local
|
||||
fix prepares all three before publishing and returns the successful current-run
|
||||
result directly to the route. Errors retain the previous coherent trio, restore
|
||||
IDLE and do not dispatch controls for that failed optimization. Strict passing
|
||||
regression tests replace the initial expected-failure audit cases.
|
||||
|
||||
## Shared acceptance before dependent PRs can land
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
| Area | Required combined check |
|
||||
| --- | --- |
|
||||
| Routing | Legacy /optimize remains GENETIC0; automatic mode selects the configured algorithm; the new explicit API selects GENETIC deliberately |
|
||||
| Configuration | Stable device IDs/maps and converters, runtime changes retained, old aliases migrated without losing values |
|
||||
| Time | 900/3600-second slots, non-hour-aligned start and advancing warmstart, timezone/DST boundaries, matching output timestamps |
|
||||
| Units | PV/load predictions are W; integrate once to slot Wh. `feed_in_tariff_wh` and `elecprice_marketprice_wh` already yield amount/Wh; convert amount/kWh configuration exactly once |
|
||||
| Tariffs | Positive/zero/negative/imported revenue remains distinct from purchase cost; missing or invalid imported data does not silently become demo data |
|
||||
| Devices | SOC/energy balance, charge/discharge and inverter limits, allowed/blocked battery export |
|
||||
| Consumers | Feature profiles/deadlines and #1256 per-cycle windows/gaps both preserved; impossible schedules fail explicitly |
|
||||
| Results | Raw algorithm result, generic optimization solution, execution plan and on-demand PDF agree on slots and device IDs |
|
||||
| Errors | Failed preparation/optimization produces no new control dispatch and no misleading fresh-success response using old results |
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
A passing individual PR is insufficient: after combining dependent packages, run
|
||||
these synthetic end-to-end API cases together with the GENETIC0 regression suite.
|
||||
No device control, HA deployment or production configuration is part of this work.
|
||||
|
||||
## Completed combined check
|
||||
|
||||
Integration source checkpoint b684748 combines tariff b2a4e2f, Optimize fix 8ed65ec,
|
||||
device/cache corrections be184a6 and 38eb4ad, and local PV 84abe05, on the refreshed
|
||||
configuration base. The combined API failure/algorithm-selection, tariff, cache,
|
||||
battery/inverter, PV, configuration/migration and both optimizer test selection
|
||||
passed: **277 passed, 3 regular skips**. No expected-failure markers were used to
|
||||
hide Optimize defects. XML: private backup integration-parallel-compatibility.xml.
|
||||
The complete measurement/channel/energy/household/capacity selection additionally
|
||||
passed **132 tests** on this combined source checkpoint, including JSON persistence.
|
||||
|
||||
This is local Windows/Python 3.11 verification, not full pinned CI or acceptance of
|
||||
the pending new GENETIC. The wider standalone PV suite separately reproduced an
|
||||
existing Windows file-timestamp test failure on unchanged main; that test was
|
||||
explicitly excluded from its reported 164-passing selection. Forecast retention
|
||||
does not establish complete horizon coverage; that remains a core-port gate.
|
||||
@@ -0,0 +1,134 @@
|
||||
# EOS: Arbeitsstand und Weg zu kleinen PRs
|
||||
|
||||
Historischer Zwischenstand. Die damals offenen GENETIC-Pakete sind inzwischen
|
||||
implementiert. Maßgeblich sind [Review-Handoff](review-handoff.md) für die acht PRs,
|
||||
Abhängigkeiten und Merge-Reihenfolge sowie [GENETIC-Rollout](genetic-rollout.md)
|
||||
für Konfiguration und manuelle Abnahme. Die Statusangaben unten dokumentieren
|
||||
frühere Arbeitsschritte.
|
||||
|
||||
Stand: 16.09.2026. Offizielles main für PR #1322: `4a37244`.
|
||||
Die lokale Integration enthält ebenfalls diesen main-Stand und den aktualisierten
|
||||
Stand von #1305 (`60b77f6`).
|
||||
|
||||
## Was jetzt möglich ist
|
||||
|
||||
Ein unabhängiger, lokal getesteter PR ist veröffentlicht: `fix/measurement-json-reload`
|
||||
auf aktuellem main. Sein Worktree ist `../EOS-pr-measurement-json`; er enthält nur
|
||||
zwei geänderte Dateien. Der veröffentlichte [PR #1322](https://github.com/Akkudoktor-EOS/EOS/pull/1322)
|
||||
enthält zwei Commits bis `ce132ea`. Der PR-Text steht in
|
||||
[measurement-json-reload.md](pr-drafts/measurement-json-reload.md).
|
||||
|
||||
Das ist ein konkreter Einstieg in den PR-Workflow. Die vollständige Übernahme aller
|
||||
Funktionen aus dem alten Feature-Branch ist noch NICHT abgeschlossen.
|
||||
|
||||
## Branches und ihre Aufgaben
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
| Branch | Zweck | Freigabezustand |
|
||||
| --- | --- | --- |
|
||||
| `fix/measurement-json-reload` | Kleiner JSON-Ladefehler direkt auf main | Veröffentlicht als #1322; gesamte CI grün, noch nicht gemergt |
|
||||
| `feat/config-integration-base` | Zusammengeführte #1256/#1305 plus Integrationskorrekturen | Veröffentlichter Vergleichsbranch 9038b65; kein konkurrierender Sammel-PR |
|
||||
| `feat/measurement-energy-quality-capacity` | Messdatenfunktionen ohne neue Optimiererphysik | PR #1326 gegen Konfigurationsbasis; später auf main umstellen |
|
||||
| `fix/imported-feedin-main` | Importierte Einspeisetarife erhalten und prüfen | Veröffentlicht als #1324; CI läuft |
|
||||
| `feat/local-pv-main-port` | Lokale PV-Prognose und Kalibrierung | PR #1325 gegen main |
|
||||
| `feat/slot-device-physics` | Slotphysik und getrennte Cache-Methoden | PR #1327 gegen Konfigurationsbasis; später auf main umstellen |
|
||||
| `fix/optimize-run-result` | Nur das Ergebnis des erfolgreichen aktuellen Laufs zurückgeben | Veröffentlicht als #1323; CI läuft |
|
||||
| `integration/eos-consolidation-20260916` | Zusammenführung und Prüfung aller Portierungspakete | Unvollständig; kein Gesamt-PR und kein HA-Release |
|
||||
| `feat/direct-marketing-battery-grid-export` | Ursprüngliche Entwicklung mit lokalen Änderungen | Unverändert erhalten und gesichert |
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Der Worktree `../EOS-pr-measurement` gehört zum Messdatenpaket.
|
||||
`../EOS-integration-20260916` bleibt der zeitlich begrenzte Portierungsarbeitsplatz.
|
||||
`../EOS-reference-20260916` bleibt der unveränderte Vergleichsstand für Basisfehler.
|
||||
Ein Worktree ist nur ein Arbeitsverzeichnis; Gegenstand eines PRs ist der Branch.
|
||||
|
||||
## Umgesetzt und geprüft
|
||||
|
||||
- Geräte-Maps, Algorithmuskonvertierung und Laufzeitkonfiguration aus #1256/#1305.
|
||||
- Korrekturen für stabile Geräte-IDs, LCOS-Migration und Ladeleistungslisten.
|
||||
- Messkanäle, Qualität, Energieintegration, Haushaltsbilanz und Kapazitätsschätzung,
|
||||
einschließlich asynchroner Speicherung und REST-Schnittstellen.
|
||||
- Im Integrationsbranch außerdem Viertelstunden-Gerätephysik, begrenzter
|
||||
Batterieexport und Wirkungsgrade; Restwert-/Prognose-Nachlauf-Bausteine.
|
||||
- GENETIC0 bleibt separat. Sein `/optimize`-Endpunkt beweist keine vollständige
|
||||
Portierung des neuen GENETIC.
|
||||
|
||||
## Noch offen
|
||||
|
||||
1. Neues GENETIC vollständig auf main-Strukturen anpassen: Viertelstundenplanung,
|
||||
Exportzustände, adaptive Evolution und zeitlich korrekter Warmstart.
|
||||
2. Flexible Lastprofile/EV-Fristen mit den Mehrfachzyklen und Zeitfenstern aus
|
||||
`#1256` verbinden. Beide vorhandenen Funktionssätze müssen erhalten bleiben.
|
||||
3. Horizont, Prognoselücken, Nachlauf und Restwert mit Optimierer und Ergebnissen
|
||||
verdrahten; bisher sind nur die Bausteine übernommen.
|
||||
4. Den bereits portierten Tarifschutz auch in der neuen GENETIC-Parametervorbereitung
|
||||
erhalten; dort Prognosegrenzen und Lücken verbindlich prüfen.
|
||||
5. Algorithmusspezifische PDF-Ausgabe und die lokale Konfigurationslern-Anfrage
|
||||
integrieren. Die lokale kalibrierte PV-Prognose ist inzwischen portiert.
|
||||
6. Gesamtabnahme einschließlich API-Weg des neuen GENETIC und gepinnter CI.
|
||||
Danach erst Übergabe eines festen EOS-Commits an HA und Release-Arbeiten.
|
||||
|
||||
## PR-Reihenfolge
|
||||
|
||||
1. JSON-Fix als PR #1322 veröffentlicht: CI und Review prüfen, danach über Merge entscheiden.
|
||||
2. `#1256/#1305` über ihre vorhandenen PRs zusammenführen; lokale Korrekturen dort
|
||||
zuordnen. Keine pauschale Veröffentlichung der kombinierten Integrationsbasis.
|
||||
3. Das isolierte Messdatenpaket auf diesen main-Stand setzen, Diff prüfen und
|
||||
nochmals testen; dann als eigenen PR einreichen.
|
||||
4. Optimierer, Tarifschutz, PV und Ausgabe jeweils als abgegrenzte Pakete fertigstellen.
|
||||
Abhängige PRs ausdrücklich als solche behandeln.
|
||||
|
||||
Für jede neue unabhängige Änderung: aktuellen `refs/remotes/origin/main` holen,
|
||||
einen Themenbranch mit eigenem Worktree starten, lokal testen, den Diff prüfen,
|
||||
dann PR gegen main. Nach Review und grüner CI mergen. Alte Worktrees erst nach
|
||||
abgeschlossener Übernahme und Prüfung lokaler Änderungen aufräumen.
|
||||
|
||||
Wegen der vorhandenen gleichnamigen lokalen Branch-Referenz ausdrücklich
|
||||
`refs/remotes/origin/main` verwenden. Keine neuen unabhängigen Funktionen auf den
|
||||
alten großen Feature-Branch oder die Integrationsbasis stapeln.
|
||||
|
||||
## Kann die laufende Entwicklung schon umziehen?
|
||||
|
||||
Unabhängige Fehlerkorrekturen und neue Funktionen können ab jetzt in Themenbranches
|
||||
auf main erfolgen. Für Entwicklung, die den vollständigen neuen GENETIC oder die
|
||||
noch fehlenden Funktionen benötigt, ist der Integrationsstand noch nicht abgenommen.
|
||||
Die ursprüngliche Arbeitskopie bleibt erhalten. JSON-Fix #1322, Optimize-Fix #1323
|
||||
und Tarifschutz #1324 sind veröffentlicht. Auf weitere Freigabe folgten PV #1325,
|
||||
Messdaten #1326 und Gerätephysik #1327; nichts wurde gemergt oder deployt.
|
||||
Der aktuelle Review-Überblick steht in [review-handoff.md](review-handoff.md).
|
||||
|
||||
## HA-Übergabe
|
||||
|
||||
Noch keinen neuen Gesamtstand pinnen oder deployen. Die geplante Schnittstelle nutzt
|
||||
Geräte-Maps mit stabilen IDs, `levelized_cost_of_storage_amt_kwh`, asynchrone
|
||||
Messdatenzugriffe und getrennte GENETIC/GENETIC0-Pfade. Details stehen in
|
||||
[eos-ha-handoff.md](eos-ha-handoff.md). HA-Dateien wurden nicht verändert.
|
||||
|
||||
## Nachweise und Grenzen
|
||||
|
||||
Der JSON-PR: 49 bestandene Tests, Ruff und Formatprüfung.
|
||||
Das isolierte Messdatenpaket: 453 bestandene Tests plus 5 Dokumentationstests;
|
||||
74 Tests nach Übernahme der Fixture-Isolation nochmals erfolgreich.
|
||||
XML-Protokolle liegen in der privaten Sicherung `eos-20260916-120324`.
|
||||
Für PR #1322 ist die gepinnte Linux/Python-3.13-CI inzwischen bestätigt:
|
||||
1.884 Tests bestanden, 16 übersprungen; Pre-commit/Mypy, CodeQL und Docker-Build
|
||||
erfolgreich auf `ce132ea`. Die aktuellen CI-Ergebnisse aller sechs PRs stehen im Review-Handoff.
|
||||
|
||||
Zusätzlicher Integrationslauf: 764 Tests bestanden, 3 übersprungen; zwei zunächst
|
||||
fehlgeschlagene Dokumentationsvergleiche betrafen ausschließlich die Versionsangabe.
|
||||
Nach Neugenerierung bestanden alle 5 Dokumentationstests. Darunter sind außerdem
|
||||
128 bestandene Energy-Charts-Regressionen zum neuen main-Commit dokumentiert.
|
||||
|
||||
Früherer gemeinsamer Source-Stand `b684748`: 277 Tests bestanden, 3 regulär
|
||||
übersprungen, für PV, Tarifschutz, Gerätephysik, Cache, Konfiguration und beide
|
||||
bisherigen Optimierer einschließlich API-Fehlerbehandlung. Anschließend bestanden
|
||||
alle 132 Messdaten-/Haushalts-/Kapazitätsprüfungen auf diesem gemeinsamen Stand.
|
||||
Die vollständige neue GENETIC-Orchestrierung bleibt offen. Pakete und
|
||||
Kompatibilitätsbedingungen stehen in [pr-integration-matrix.md](pr-integration-matrix.md).
|
||||
Die neu generierte gemeinsame Dokumentation besteht ebenfalls alle fünf Prüfungen.
|
||||
|
||||
Aktueller Abschluss: Alle sechs veröffentlichten PRs haben ihre vorgesehenen
|
||||
GitHub-Prüfungen bestanden. Gemeinsamer Stand: 253 Dateien ohne Mypy-Fehler,
|
||||
37 gezielte Nachprüfungen und vollständiger Sphinx-Build erfolgreich. Die Grenzen
|
||||
des Windows-Gesamtlaufs und die nachgewiesenen main-Baselinefehler sind im
|
||||
[Review-Handoff](review-handoff.md) dokumentiert. Nichts wurde gemergt oder deployt.
|
||||
@@ -0,0 +1,60 @@
|
||||
# EOS review and merge handoff
|
||||
|
||||
The consolidation implements the remaining GENETIC optimizer, configuration-owned
|
||||
Optimize request and result/PDF output. See [GENETIC rollout](genetic-rollout.md)
|
||||
for configuration and manual acceptance. Historical planning documents in this
|
||||
directory describe earlier checkpoints; this handoff supersedes their pending-work lists.
|
||||
|
||||
## Integration status
|
||||
|
||||
The seven prerequisite packages #1322-#1328 are merged into main. PR #1329 was
|
||||
merged into `integration/genetic-prerequisites`, not into main. The final delivery
|
||||
branch `feat/genetic-complete-main-port` brings that complete implementation directly
|
||||
to main. Until that final PR is merged, main lacks the complete GENETIC port.
|
||||
|
||||
The foundation preserves the original #1256/#1305 contributions and compatibility
|
||||
corrections; do not merge those original PRs again as extra prerequisites.
|
||||
|
||||
Main `3c86254` has exactly the production source tree of the original prerequisite
|
||||
integration `5eacd54`. Only generated API version strings and one corrected test
|
||||
import differ. The final delivery preserves the complete production source and
|
||||
tests from #1329, including that corrected import, and regenerates API/configuration
|
||||
documents from the combined code. Its merge conflicts arise from the rewritten
|
||||
squash ancestry, not from additional production changes on main.
|
||||
|
||||
Review and merge the final PR with **main** as its target and all four current-head
|
||||
checks green: pytest, pre-commit, Docker and CodeQL. Squash and merge is supported.
|
||||
Do not use a merge into the comparison branch as a release. Keep the original
|
||||
working copy and backup until manual installation acceptance is complete.
|
||||
|
||||
## Review and validation
|
||||
|
||||
- Optimize: preparation or conversion failure never returns a previous successful
|
||||
result; native result, generic solution and execution plan publish atomically.
|
||||
- Forecasts: provider power is converted from W to slot Wh exactly once. Raw missing
|
||||
records stay missing, control coverage is mandatory, and a shorter tail is clipped.
|
||||
- Economics: explicit/imported sale prices remain authoritative, including zero and
|
||||
negative values. Battery export is opt-in; terminal value is separate from LCOS.
|
||||
- Time: 15/60-minute slots, repeated DST hours, local-midnight forecast origins and
|
||||
non-integer timezone offsets are covered. Old GENETIC reports use saved timestamps.
|
||||
- Devices: EV deadlines, flexible power profiles, crossed per-cycle windows, completed
|
||||
cycles and minimum gaps are tested through the optimizer and result conversion.
|
||||
- Compatibility: GENETIC0 keeps its legacy request and device implementation. Both
|
||||
algorithms retain finite solution validity and existing persistent plan instructions.
|
||||
|
||||
Exact workflow results belong to each PR's current head; superseded green heads do
|
||||
not prove a later revision. The combined checks cover pinned mypy, generated OpenAPI
|
||||
and configuration, physics, native HTTP, automatic preparation, PDF generation and
|
||||
the 400-generation optimizer regression.
|
||||
|
||||
Local Windows server-PID tests and Docker builds have known environment failures
|
||||
that also reproduce on unchanged main. Linux CI remains the full-suite gate. Local
|
||||
PDF pixel comparison needs an optional converter; report semantics and PDF bytes
|
||||
are tested independently. Production HA/device behavior still needs manual acceptance.
|
||||
|
||||
## Preservation and release boundary
|
||||
|
||||
The original feature working copy is read-only. Its original HEAD and 102 saved files
|
||||
are checked against the private preservation manifest. Runtime configuration, secrets
|
||||
and real measurements are excluded from the PRs. No remote PR is merged and no HA
|
||||
configuration, production server or physical device is changed by this preparation.
|
||||
+1385
-41
File diff suppressed because it is too large
Load Diff
@@ -339,6 +339,37 @@ class SettingsEOSDefaults(SettingsEOS):
|
||||
# This is mutable, so pydantic does not set a hash.
|
||||
return id(self)
|
||||
|
||||
def validate_optimization_horizons(self) -> "SettingsEOSDefaults":
|
||||
"""Report a forecast budget that cannot serve the optimization horizons.
|
||||
|
||||
This never rejects a configuration. ``prediction.hours`` is a general
|
||||
setting that also serves callers with nothing to do with optimization,
|
||||
and refusing it here would stop EOS from starting over a horizon the
|
||||
user may not even optimize on. A tail that does not fit is simply
|
||||
shortened, and a control horizon that does not fit is caught by the
|
||||
optimizer itself, which knows exactly which forecast series ran out.
|
||||
"""
|
||||
control = self.optimization.genetic.horizon_hours
|
||||
tail = self.optimization.genetic.tail_horizon_hours
|
||||
prediction = self.prediction.hours
|
||||
if prediction is None or prediction < control:
|
||||
logger.warning(
|
||||
"Prediction horizon {} h is shorter than the {} h control horizon. Optimization "
|
||||
"runs will fail until prediction.hours covers the control horizon.",
|
||||
prediction,
|
||||
control,
|
||||
)
|
||||
elif prediction < control + tail:
|
||||
logger.info(
|
||||
"Prediction horizon {} h covers the {} h control horizon but not the requested "
|
||||
"{} h tail. The tail is shortened to {} h; raise prediction.hours to use it fully.",
|
||||
prediction,
|
||||
control,
|
||||
tail,
|
||||
prediction - control,
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
"""Singleton configuration handler for the EOS application.
|
||||
|
||||
@@ -269,6 +269,12 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
|
||||
# Normalize this provider before the generic field-by-field transfer. Validate
|
||||
# coupled bounds together so a transient intermediate default cannot lose them.
|
||||
config_data = dict(config_data)
|
||||
from akkudoktoreos.optimization.genetic.geneticsettings import (
|
||||
normalize_genetic_settings,
|
||||
)
|
||||
|
||||
if "optimization" in config_data:
|
||||
config_data["optimization"] = normalize_genetic_settings(config_data["optimization"])
|
||||
pv_settings = normalize_akkudoktor_settings(config_data.get("pvforecast"))
|
||||
if isinstance(pv_settings, dict):
|
||||
config_data["pvforecast"] = pv_settings
|
||||
|
||||
@@ -1137,7 +1137,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecordT], Generic[Da
|
||||
async for record in self.db_iterate_records(start_timestamp, end_timestamp):
|
||||
if (
|
||||
record.date_time is None
|
||||
or (getattr(record, key, None) is None) # key is not in record
|
||||
or not hasattr(record, key) # key is not in record
|
||||
or (dropna and pd.isna(getattr(record, key, None)))
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -2234,7 +2234,9 @@ class EnergyManagementPlan(PydanticBaseModel):
|
||||
self.valid_until = None
|
||||
return
|
||||
|
||||
self.valid_from = min(i.execution_time for i in self.instructions)
|
||||
self.valid_from = min(
|
||||
(i.execution_time for i in self.instructions), key=lambda value: value.timestamp()
|
||||
)
|
||||
|
||||
end_times = []
|
||||
for instr in self.instructions:
|
||||
@@ -2245,12 +2247,14 @@ class EnergyManagementPlan(PydanticBaseModel):
|
||||
return
|
||||
end_times.append(instr.execution_time + instr_duration)
|
||||
|
||||
self.valid_until = max(end_times) if end_times else None
|
||||
self.valid_until = (
|
||||
max(end_times, key=lambda value: value.timestamp()) if end_times else None
|
||||
)
|
||||
|
||||
def add_instruction(self, instruction: EnergyManagementInstruction) -> None:
|
||||
"""Adds a new control instruction and updates time range."""
|
||||
self.instructions.append(instruction)
|
||||
self.instructions.sort(key=lambda i: i.execution_time)
|
||||
self.instructions.sort(key=lambda i: i.execution_time.timestamp())
|
||||
self._update_time_range()
|
||||
|
||||
def clear(self) -> None:
|
||||
@@ -2289,14 +2293,14 @@ class EnergyManagementPlan(PydanticBaseModel):
|
||||
by_resource: dict[str, list["EnergyManagementInstruction"]] = defaultdict(list)
|
||||
for instr in self.instructions:
|
||||
# skip instructions scheduled in the future
|
||||
if instr.execution_time <= now:
|
||||
if instr.execution_time.timestamp() <= now.timestamp():
|
||||
by_resource[instr.resource_id].append(instr)
|
||||
|
||||
active: list["EnergyManagementInstruction"] = []
|
||||
|
||||
for resource_id, instrs in by_resource.items():
|
||||
# pick latest instruction by execution_time
|
||||
latest = max(instrs, key=lambda i: i.execution_time)
|
||||
latest = max(instrs, key=lambda i: i.execution_time.timestamp())
|
||||
|
||||
if len(instrs) == 0:
|
||||
# No instructions, ther shall be at least one
|
||||
@@ -2310,7 +2314,7 @@ class EnergyManagementPlan(PydanticBaseModel):
|
||||
active.append(latest)
|
||||
else:
|
||||
# active only if now is strictly before execution_time + duration
|
||||
if latest.execution_time + instr_duration > now:
|
||||
if (latest.execution_time + instr_duration).timestamp() > now.timestamp():
|
||||
active.append(latest)
|
||||
|
||||
return active
|
||||
@@ -2320,9 +2324,11 @@ class EnergyManagementPlan(PydanticBaseModel):
|
||||
) -> Optional[EnergyManagementInstruction]:
|
||||
"""Finds the next instruction scheduled after the specified time."""
|
||||
now = now or to_datetime()
|
||||
future_instructions = [i for i in self.instructions if i.execution_time > now]
|
||||
future_instructions = [
|
||||
i for i in self.instructions if i.execution_time.timestamp() > now.timestamp()
|
||||
]
|
||||
return (
|
||||
min(future_instructions, key=lambda i: i.execution_time)
|
||||
min(future_instructions, key=lambda i: i.execution_time.timestamp())
|
||||
if future_instructions
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from akkudoktoreos.optimization.genetic0.genetic0params import (
|
||||
Genetic0OptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic0.genetic0solution import Genetic0Solution
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
@@ -63,6 +64,7 @@ class EnergyManagement(
|
||||
|
||||
# Start datetime.
|
||||
_start_datetime: ClassVar[Optional[DateTime]] = None
|
||||
_observation_datetime: ClassVar[Optional[DateTime]] = None
|
||||
|
||||
# last run datetime. Used by energy management task
|
||||
_last_run_datetime: ClassVar[Optional[DateTime]] = None
|
||||
@@ -101,25 +103,47 @@ class EnergyManagement(
|
||||
"""The datetime the last energy management was run."""
|
||||
return EnergyManagement._last_run_datetime
|
||||
|
||||
@property
|
||||
def observation_datetime(self) -> DateTime:
|
||||
"""Unrounded run time used to assess the freshness of runtime measurements."""
|
||||
return EnergyManagement._observation_datetime or self.start_datetime
|
||||
|
||||
@classmethod
|
||||
def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime:
|
||||
def set_start_datetime(
|
||||
cls, start_datetime: Optional[DateTime] = None, interval_seconds: Optional[int] = None
|
||||
) -> DateTime:
|
||||
"""Set the start datetime for the next energy management run.
|
||||
|
||||
If no datetime is provided, the current datetime is used.
|
||||
|
||||
The start datetime is always rounded down to the nearest hour
|
||||
(i.e., setting minutes, seconds, and microseconds to zero).
|
||||
The start is rounded down to the selected algorithm slot using elapsed
|
||||
time, retaining the correct offset during repeated DST hours.
|
||||
|
||||
Args:
|
||||
start_datetime (Optional[DateTime]): The datetime to set as the start.
|
||||
If None, the current datetime is used.
|
||||
interval_seconds: Explicit slot duration; otherwise use the configured algorithm.
|
||||
|
||||
Returns:
|
||||
DateTime: The adjusted start datetime.
|
||||
"""
|
||||
if start_datetime is None:
|
||||
start_datetime = to_datetime()
|
||||
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
|
||||
cls._observation_datetime = start_datetime
|
||||
if interval_seconds is None:
|
||||
interval_seconds = (
|
||||
cls.config.optimization.genetic.interval_sec
|
||||
if cls.config.optimization.algorithm == OptimizationAlgorithm.GENETIC
|
||||
else 3600
|
||||
)
|
||||
if interval_seconds not in (900, 3600):
|
||||
raise ValueError("Optimization slots must be 900 or 3600 seconds.")
|
||||
midnight = start_datetime.start_of("day").int_timestamp
|
||||
elapsed = start_datetime.int_timestamp - midnight
|
||||
cls._start_datetime = to_datetime(
|
||||
midnight + (elapsed // interval_seconds) * interval_seconds,
|
||||
in_timezone=start_datetime.timezone_name,
|
||||
)
|
||||
return cls._start_datetime
|
||||
|
||||
@classmethod
|
||||
@@ -172,8 +196,11 @@ class EnergyManagement(
|
||||
start_datetime: Optional[DateTime] = None,
|
||||
mode: Optional[EnergyManagementMode] = None,
|
||||
algorithm: Optional[OptimizationAlgorithm] = None,
|
||||
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
|
||||
genetic_parameters: Optional[
|
||||
GeneticOptimizationParameters | ConfigOptimizationRequest
|
||||
] = None,
|
||||
genetic_generations: Optional[int] = None,
|
||||
genetic_individuals: Optional[int] = None,
|
||||
genetic_seed: Optional[int] = None,
|
||||
genetic0_parameters: Optional[Genetic0OptimizationParameters] = None,
|
||||
genetic0_generations: Optional[int] = None,
|
||||
@@ -207,6 +234,7 @@ class EnergyManagement(
|
||||
genetic_generations (int, optional): The number of generations for the
|
||||
`GENETIC` algorithm. Defaults to the algorithm's internal default (400)
|
||||
if not specified.
|
||||
genetic_individuals (int, optional): Population size override for this run.
|
||||
genetic_seed (int, optional): The seed for the `GENETIC` algorithm. Defaults
|
||||
to the algorithm's internal random seed if not specified.
|
||||
genetic0_parameters (Genetic0OptimizationParameters, optional): The
|
||||
@@ -245,7 +273,20 @@ class EnergyManagement(
|
||||
|
||||
# Remember/ set the start datetime of this energy management run.
|
||||
# None leads to current time as start datetime
|
||||
self.set_start_datetime(start_datetime)
|
||||
if algorithm is None:
|
||||
algorithm = self.config.optimization.algorithm
|
||||
if start_datetime is None and algorithm == OptimizationAlgorithm.GENETIC:
|
||||
# Consumer windows and midnight-based forecasts use the site clock,
|
||||
# which can differ from the server's local timezone.
|
||||
start_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
self.set_start_datetime(
|
||||
start_datetime,
|
||||
interval_seconds=(
|
||||
self.config.optimization.genetic.interval_sec
|
||||
if algorithm == OptimizationAlgorithm.GENETIC
|
||||
else 3600
|
||||
),
|
||||
)
|
||||
|
||||
# Throw away any memory cached results of the last energy management run.
|
||||
CacheEnergyManagementStore().clear()
|
||||
@@ -296,15 +337,19 @@ class EnergyManagement(
|
||||
# Prepare optimization parameters
|
||||
# This also creates default configurations for missing values and updates the predictions
|
||||
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
|
||||
if genetic_parameters is None:
|
||||
genetic_parameters = await GeneticOptimizationParameters.prepare()
|
||||
try:
|
||||
if isinstance(genetic_parameters, ConfigOptimizationRequest):
|
||||
genetic_parameters = await genetic_parameters.resolve()
|
||||
elif genetic_parameters is None:
|
||||
genetic_parameters = await GeneticOptimizationParameters.prepare()
|
||||
if genetic_parameters is None:
|
||||
logger.error(
|
||||
f"{algorithm}: Energy management run canceled. "
|
||||
"Could not prepare optimisation parameters."
|
||||
)
|
||||
logger.error("GENETIC: Parameter preparation failed; canceling run.")
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("GENETIC: Parameter preparation failed.")
|
||||
EnergyManagement._stage = EnergyManagementStage.IDLE
|
||||
return None
|
||||
|
||||
# Take values from config if not given
|
||||
if genetic_generations is None:
|
||||
@@ -332,6 +377,7 @@ class EnergyManagement(
|
||||
GeneticOptimizationParameters, genetic_parameters
|
||||
), # cast for mypy
|
||||
ngen=genetic_generations,
|
||||
individuals=genetic_individuals,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Abstract and base classes for devices."""
|
||||
|
||||
import math
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BatteryOperationMode(StrEnum):
|
||||
@@ -71,6 +73,115 @@ class BatteryOperationMode(StrEnum):
|
||||
FAULT = "FAULT"
|
||||
|
||||
|
||||
def validate_home_appliance_load_definition(
|
||||
*,
|
||||
load_profile_power_w: Optional[list[float]],
|
||||
load_profile_interval_seconds: Optional[int],
|
||||
consumption_wh: Optional[float],
|
||||
duration_h: Optional[float],
|
||||
) -> None:
|
||||
"""Validate the load definition of a flexible consumer / home appliance.
|
||||
|
||||
A consumer's load must be given **either** as a full explicit power profile
|
||||
(``load_profile_power_w``) **or** as the complete flat fallback
|
||||
(``consumption_wh`` together with ``duration_h``). Providing both, or only a
|
||||
part of the fallback, is rejected. Profile values must be finite and
|
||||
non-negative and the profile interval, if given, must be positive.
|
||||
|
||||
Args:
|
||||
load_profile_power_w: Explicit per-step power values [W], or None.
|
||||
load_profile_interval_seconds: Duration of one profile step [s], or None.
|
||||
consumption_wh: Fallback total energy of one run [Wh], or None.
|
||||
duration_h: Fallback run duration [h], or None.
|
||||
|
||||
Raises:
|
||||
ValueError: If the definition is conflicting, incomplete, or contains
|
||||
invalid profile values.
|
||||
"""
|
||||
profile_given = load_profile_power_w is not None
|
||||
fallback_fields = (consumption_wh, duration_h)
|
||||
fallback_partial = any(field is not None for field in fallback_fields)
|
||||
fallback_given = all(field is not None for field in fallback_fields)
|
||||
|
||||
if profile_given and fallback_partial:
|
||||
raise ValueError(
|
||||
"Conflicting home appliance load definition: provide either "
|
||||
"load_profile_power_w or consumption_wh together with duration_h, "
|
||||
"not both."
|
||||
)
|
||||
|
||||
if load_profile_power_w is None:
|
||||
if not fallback_given:
|
||||
raise ValueError(
|
||||
"Incomplete home appliance load definition: provide a full "
|
||||
"load_profile_power_w or both consumption_wh and duration_h."
|
||||
)
|
||||
# Value ranges of the fallback fields are enforced by their Field
|
||||
# constraints (gt=0); nothing more to check here.
|
||||
return
|
||||
|
||||
# Explicit profile path.
|
||||
if load_profile_interval_seconds is not None and load_profile_interval_seconds <= 0:
|
||||
raise ValueError("load_profile_interval_seconds must be greater than zero.")
|
||||
if len(load_profile_power_w) == 0:
|
||||
raise ValueError("load_profile_power_w must not be empty.")
|
||||
for value in load_profile_power_w:
|
||||
if value is None or math.isnan(value) or math.isinf(value):
|
||||
raise ValueError(
|
||||
"load_profile_power_w must contain only finite values (no NaN or infinity)."
|
||||
)
|
||||
if value < 0:
|
||||
raise ValueError("load_profile_power_w must not contain negative values.")
|
||||
|
||||
|
||||
class ConsumerScheduleMode(StrEnum):
|
||||
"""Schedule mode of a flexible consumer (home appliance).
|
||||
|
||||
Determines how often a consumer's load profile is scheduled within the
|
||||
optimization horizon.
|
||||
|
||||
Modes
|
||||
-----
|
||||
- ONCE:
|
||||
The consumer runs exactly once somewhere within the optimization
|
||||
horizon ("fire and forget"). The optimizer picks the start.
|
||||
|
||||
- DAILY:
|
||||
The consumer runs once per local calendar day, but only on days for
|
||||
which at least one complete, allowed run still fits into the remaining
|
||||
horizon. The optimizer picks one start per eligible day.
|
||||
"""
|
||||
|
||||
ONCE = "ONCE"
|
||||
DAILY = "DAILY"
|
||||
|
||||
|
||||
class ConsumerDeadlinePolicy(StrEnum):
|
||||
"""Behaviour when a flexible consumer's deadline cannot be met.
|
||||
|
||||
A deadline (``deadline_datetime``) demands that a complete run has *finished*
|
||||
before that moment. Depending on "now", the run duration, the optimization
|
||||
horizon and the allowed time windows, no such start may exist.
|
||||
|
||||
Policies
|
||||
--------
|
||||
- BEST_EFFORT:
|
||||
Run as early as the remaining constraints allow, i.e. minimize the
|
||||
delay instead of the cost ("it should have been done by 03:00, so
|
||||
start now"). A warning is logged. This keeps an optimization request
|
||||
answerable instead of failing it - the usual choice for home
|
||||
automation.
|
||||
|
||||
- STRICT:
|
||||
Keep the deadline. A ONCE consumer without a feasible start makes the
|
||||
optimization fail; a DAILY consumer is simply not scheduled on days
|
||||
without a feasible start.
|
||||
"""
|
||||
|
||||
BEST_EFFORT = "BEST_EFFORT"
|
||||
STRICT = "STRICT"
|
||||
|
||||
|
||||
class ApplianceOperationMode(StrEnum):
|
||||
"""Appliance operation modes.
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
|
||||
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
||||
|
||||
|
||||
def max_charging_power_field(description: Optional[str] = None) -> float:
|
||||
@@ -122,6 +123,39 @@ class ElectricVehicleParameters(BaseBatteryParameters):
|
||||
"An integer representing the current state of charge (SOC) of the battery in percentage."
|
||||
)
|
||||
|
||||
min_soc_deadline_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Absolute moment by which 'min_soc_percentage' has to be "
|
||||
"reached (departure time). A date time without timezone is read "
|
||||
"as local time. None means end of the optimization horizon."
|
||||
),
|
||||
"examples": [None, "2026-07-16T07:00:00+02:00"],
|
||||
},
|
||||
)
|
||||
min_soc_max_duration_h: Optional[float] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
allow_inf_nan=False,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Maximum time from the start of the optimization until "
|
||||
"'min_soc_percentage' has to be reached [h]. Combined with "
|
||||
"'min_soc_deadline_datetime' the earlier of the two applies."
|
||||
),
|
||||
"examples": [None, 6.0],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("min_soc_deadline_datetime", mode="before")
|
||||
@classmethod
|
||||
def transform_deadline_to_datetime(cls, value: Any) -> Optional[DateTime]:
|
||||
"""Accept the usual date time representations, naive input is local time."""
|
||||
if value is None:
|
||||
return None
|
||||
return to_datetime(value)
|
||||
|
||||
|
||||
class Battery:
|
||||
"""Represents a battery device with methods to simulate energy charging and discharging."""
|
||||
|
||||
@@ -5,7 +5,7 @@ machines that must run for a fixed duration, possibly multiple times
|
||||
(cycles), within one or more allowed time windows. Given a set of
|
||||
requested start times, `HomeAppliance` repairs them into a
|
||||
feasible, chronologically ordered schedule and produces the resulting
|
||||
hourly load curve.
|
||||
per-slot energy curve.
|
||||
|
||||
Time windows are always expressed as a `CycleTimeWindowSequence`
|
||||
(see ``akkudoktoreos.config.configabc``): each contained window's
|
||||
@@ -29,22 +29,92 @@ Cycle start times are repaired according to the following rules:
|
||||
5. Generate the combined hourly load curve from the final starts.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Optional, Self
|
||||
|
||||
import numpy as np
|
||||
from pydantic import Field
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import CycleTimeWindowSequence, ValueTimeWindow
|
||||
from akkudoktoreos.config.configabc import (
|
||||
CycleTimeWindowSequence,
|
||||
TimeWindow,
|
||||
TimeWindowSequence,
|
||||
ValueTimeWindow,
|
||||
)
|
||||
from akkudoktoreos.devices.devicesabc import (
|
||||
ConsumerDeadlinePolicy,
|
||||
ConsumerScheduleMode,
|
||||
validate_home_appliance_load_definition,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
DateTime,
|
||||
Duration,
|
||||
compare_datetimes,
|
||||
to_datetime,
|
||||
to_duration,
|
||||
to_time,
|
||||
)
|
||||
|
||||
|
||||
def resample_power_to_slot_energy(
|
||||
power_w: list[float],
|
||||
input_interval_seconds: float,
|
||||
slot_interval_seconds: float,
|
||||
) -> np.ndarray:
|
||||
"""Resample a piecewise-constant power profile to per-slot energy.
|
||||
|
||||
Each input value ``power_w[i]`` is interpreted as a constant power [W] over
|
||||
the interval ``[i * input_interval_seconds, (i + 1) * input_interval_seconds)``.
|
||||
The energy of every output slot is the time-weighted integral of the input
|
||||
power over that slot::
|
||||
|
||||
E_j = sum_i P_i * overlap(i, j) / 3600 [Wh]
|
||||
|
||||
where ``overlap(i, j)`` is the temporal overlap (in seconds) between input
|
||||
interval ``i`` and output slot ``j``. This is exact for arbitrary (including
|
||||
non-integer) ratios such as 10 -> 15 or 20 -> 15 minutes and conserves
|
||||
energy within numerical tolerance::
|
||||
|
||||
sum_j E_j == sum_i P_i * input_interval_seconds / 3600
|
||||
|
||||
Args:
|
||||
power_w: Piecewise-constant power values [W] of a single run.
|
||||
input_interval_seconds: Duration of one input step [s] (> 0).
|
||||
slot_interval_seconds: Duration of one output slot [s] (> 0).
|
||||
|
||||
Returns:
|
||||
1-D array of per-slot energy [Wh]; length is the number of slots the run
|
||||
occupies (ceil of the total run duration divided by the slot duration).
|
||||
"""
|
||||
if not math.isfinite(input_interval_seconds) or input_interval_seconds <= 0:
|
||||
raise ValueError("Input interval must be finite and positive.")
|
||||
if not math.isfinite(slot_interval_seconds) or slot_interval_seconds <= 0:
|
||||
raise ValueError("Slot interval must be finite and positive.")
|
||||
if not power_w or any(not math.isfinite(p) or p < 0 for p in power_w):
|
||||
raise ValueError("Power profile must contain finite non-negative values.")
|
||||
n_in = len(power_w)
|
||||
total_seconds = n_in * input_interval_seconds
|
||||
n_slots = int(np.ceil(total_seconds / slot_interval_seconds - 1e-9))
|
||||
out = np.zeros(max(n_slots, 0), dtype=float)
|
||||
for i, power in enumerate(power_w):
|
||||
if power == 0.0:
|
||||
continue
|
||||
seg_start = i * input_interval_seconds
|
||||
seg_end = seg_start + input_interval_seconds
|
||||
first = int(seg_start // slot_interval_seconds)
|
||||
last = int((seg_end - 1e-9) // slot_interval_seconds)
|
||||
for j in range(first, last + 1):
|
||||
slot_start = j * slot_interval_seconds
|
||||
slot_end = slot_start + slot_interval_seconds
|
||||
overlap = min(seg_end, slot_end) - max(seg_start, slot_start)
|
||||
if overlap > 0:
|
||||
out[j] += power * overlap / 3600.0
|
||||
return out
|
||||
|
||||
|
||||
class HomeApplianceParameters(DeviceParameters):
|
||||
"""Configuration for a simulated home appliance device."""
|
||||
|
||||
@@ -54,7 +124,8 @@ class HomeApplianceParameters(DeviceParameters):
|
||||
"examples": ["dishwasher"],
|
||||
}
|
||||
)
|
||||
consumption_wh: int = Field(
|
||||
consumption_wh: Optional[int] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
@@ -64,7 +135,8 @@ class HomeApplianceParameters(DeviceParameters):
|
||||
"examples": [2000],
|
||||
},
|
||||
)
|
||||
duration_h: int = Field(
|
||||
duration_h: Optional[int] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
@@ -81,6 +153,10 @@ class HomeApplianceParameters(DeviceParameters):
|
||||
"examples": [2],
|
||||
},
|
||||
)
|
||||
completed_cycles: int = Field(
|
||||
default=0, ge=0, description="Cycles already completed on the first planning day."
|
||||
)
|
||||
|
||||
min_cycle_gap_h: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
@@ -113,6 +189,112 @@ class HomeApplianceParameters(DeviceParameters):
|
||||
},
|
||||
)
|
||||
|
||||
shared_time_windows: Optional[TimeWindowSequence] = Field(
|
||||
default=None,
|
||||
description="Allowed recurring windows shared by every cycle; intersected with per-cycle windows.",
|
||||
)
|
||||
|
||||
load_profile_power_w: Optional[list[float]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Explicit load profile describing a single complete run as a "
|
||||
"sequence of non-negative power values in watts. Each value "
|
||||
"covers 'load_profile_interval_seconds'. Mutually exclusive with "
|
||||
"consumption_wh/duration_h."
|
||||
),
|
||||
"examples": [[200.0, 2000.0, 1800.0, 100.0]],
|
||||
},
|
||||
)
|
||||
load_profile_interval_seconds: Optional[int] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Duration of one 'load_profile_power_w' step in seconds. Defaults "
|
||||
"to the configured optimization interval when a profile is given."
|
||||
),
|
||||
"examples": [900, 3600],
|
||||
},
|
||||
)
|
||||
schedule_mode: ConsumerScheduleMode = Field(
|
||||
default=ConsumerScheduleMode.ONCE,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Scheduling mode: ONCE (a single run within the horizon) or DAILY "
|
||||
"(one run per local calendar day with a feasible full run)."
|
||||
),
|
||||
"examples": ["ONCE", "DAILY"],
|
||||
},
|
||||
)
|
||||
earliest_start_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Absolute earliest moment the run may start. Starts before it are "
|
||||
"dropped, in addition to 'time_windows' and the horizon. A date "
|
||||
"time without timezone is read as local time. This bound is never "
|
||||
"relaxed."
|
||||
),
|
||||
"examples": [None, "2026-07-15T20:00:00+02:00"],
|
||||
},
|
||||
)
|
||||
deadline_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Absolute deadline: the complete run must have *finished* at or "
|
||||
"before this moment (e.g. end of the day, or 03:00 tonight). A "
|
||||
"date time without timezone is read as local time. See "
|
||||
"'deadline_policy' for what happens when no start can meet it."
|
||||
),
|
||||
"examples": [None, "2026-07-16T03:00:00+02:00"],
|
||||
},
|
||||
)
|
||||
deadline_policy: ConsumerDeadlinePolicy = Field(
|
||||
default=ConsumerDeadlinePolicy.BEST_EFFORT,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"What to do when 'deadline_datetime' cannot be met: BEST_EFFORT "
|
||||
"runs as early as possible instead (warning logged), STRICT keeps "
|
||||
"the deadline (a ONCE consumer then fails the optimization)."
|
||||
),
|
||||
"examples": ["BEST_EFFORT", "STRICT"],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("earliest_start_datetime", "deadline_datetime", mode="before")
|
||||
@classmethod
|
||||
def transform_to_datetime(cls, value: Any) -> Optional[DateTime]:
|
||||
"""Accept the usual date time representations, naive input is local time."""
|
||||
if value is None:
|
||||
return None
|
||||
return to_datetime(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_load_definition(self) -> Self:
|
||||
"""Ensure a complete load definition and valid completed-cycle count."""
|
||||
if self.completed_cycles > self.num_cycles:
|
||||
raise ValueError("completed_cycles must not exceed num_cycles.")
|
||||
validate_home_appliance_load_definition(
|
||||
load_profile_power_w=self.load_profile_power_w,
|
||||
load_profile_interval_seconds=self.load_profile_interval_seconds,
|
||||
consumption_wh=self.consumption_wh,
|
||||
duration_h=self.duration_h,
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_schedule_bounds(self) -> Self:
|
||||
"""Reject an empty scheduling interval."""
|
||||
if self.earliest_start_datetime is not None and self.deadline_datetime is not None:
|
||||
if compare_datetimes(self.deadline_datetime, self.earliest_start_datetime).le:
|
||||
raise ValueError(
|
||||
f"deadline_datetime {self.deadline_datetime} must be after "
|
||||
f"earliest_start_datetime {self.earliest_start_datetime}."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class HomeAppliance:
|
||||
"""Non-vectorized simulation of a multi-cycle home appliance.
|
||||
@@ -127,6 +309,7 @@ class HomeAppliance:
|
||||
parameters: HomeApplianceParameters,
|
||||
optimization_hours: int,
|
||||
prediction_hours: int,
|
||||
slot_duration_h: float = 1.0,
|
||||
) -> None:
|
||||
"""Initializes the appliance and builds its allowed-start masks.
|
||||
|
||||
@@ -135,18 +318,32 @@ class HomeAppliance:
|
||||
optimization_hours: Number of hours under active
|
||||
optimization.
|
||||
prediction_hours: Length of the simulation horizon, in
|
||||
hours.
|
||||
slots.
|
||||
slot_duration_h: Physical duration of each slot, in hours.
|
||||
"""
|
||||
self.parameters = parameters
|
||||
self.optimization_hours = optimization_hours
|
||||
self.prediction_hours = prediction_hours
|
||||
|
||||
self.duration_h = parameters.duration_h
|
||||
self.consumption_wh = parameters.consumption_wh
|
||||
if not math.isfinite(slot_duration_h) or slot_duration_h <= 0:
|
||||
raise ValueError("Slot duration must be finite and positive.")
|
||||
self.total_slots = prediction_hours
|
||||
self.slot_duration_h = slot_duration_h
|
||||
self.slot_interval_seconds = slot_duration_h * 3600
|
||||
self.device_id = parameters.device_id
|
||||
self.schedule_mode = parameters.schedule_mode
|
||||
self.time_windows = parameters.shared_time_windows
|
||||
self.earliest_start_datetime = parameters.earliest_start_datetime
|
||||
self.deadline_datetime = parameters.deadline_datetime
|
||||
self.deadline_policy = parameters.deadline_policy
|
||||
self.deadline_relaxed = False
|
||||
self._build_run_profile()
|
||||
self.duration_h = self.run_slots * slot_duration_h
|
||||
self.consumption_wh = float(self.run_energy_wh.sum())
|
||||
self.num_cycles = parameters.num_cycles
|
||||
self.min_cycle_gap_h = parameters.min_cycle_gap_h
|
||||
|
||||
self.completed_cycles = 0
|
||||
self.completed_cycles = parameters.completed_cycles
|
||||
|
||||
self.load_curve = np.zeros(prediction_hours)
|
||||
|
||||
@@ -173,6 +370,280 @@ class HomeAppliance:
|
||||
|
||||
self._setup()
|
||||
|
||||
def _build_run_profile(self) -> None:
|
||||
"""Build the per-slot energy [Wh] of a single complete run."""
|
||||
if self.parameters.load_profile_power_w is not None:
|
||||
power = [float(value) for value in self.parameters.load_profile_power_w]
|
||||
input_interval = (
|
||||
self.parameters.load_profile_interval_seconds or self.slot_interval_seconds
|
||||
)
|
||||
else:
|
||||
# Flat fallback: constant power over duration_h hours. Route it through
|
||||
# the same resampling path so hourly and sub-hourly grids behave
|
||||
# identically. Power [W] = energy per hour = consumption_wh / duration_h.
|
||||
duration_h = self.parameters.duration_h
|
||||
consumption_wh = self.parameters.consumption_wh
|
||||
if duration_h is None or consumption_wh is None:
|
||||
raise ValueError("Flat appliance load requires duration and consumption.")
|
||||
power = [consumption_wh / duration_h]
|
||||
input_interval = duration_h * 3600
|
||||
|
||||
self.run_energy_wh: np.ndarray = resample_power_to_slot_energy(
|
||||
power, float(input_interval), float(self.slot_interval_seconds)
|
||||
)
|
||||
self.run_slots: int = int(len(self.run_energy_wh))
|
||||
|
||||
def _slot_offset(self, moment: DateTime, slot0_datetime: DateTime, *, round_up: bool) -> int:
|
||||
"""Convert an absolute moment into a slot index relative to slot 0.
|
||||
|
||||
Args:
|
||||
moment: Absolute moment; converted into ``slot0_datetime``'s timezone.
|
||||
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||
round_up: ``True`` returns the first slot boundary at or after
|
||||
``moment`` (lower bounds), ``False`` the last one at or before
|
||||
it (upper bounds).
|
||||
|
||||
Returns:
|
||||
Slot index (may be negative or beyond the grid; callers clamp).
|
||||
"""
|
||||
timezone = slot0_datetime.timezone
|
||||
if timezone is None:
|
||||
raise ValueError("The optimization slot origin must have a timezone.")
|
||||
seconds = (moment.in_timezone(timezone) - slot0_datetime).total_seconds()
|
||||
exact = seconds / self.slot_interval_seconds
|
||||
# Tolerance absorbs float noise so a moment that sits exactly on a slot
|
||||
# boundary is not pushed to the neighbouring slot.
|
||||
return math.ceil(exact - 1e-9) if round_up else math.floor(exact + 1e-9)
|
||||
|
||||
def allowed_start_slots(
|
||||
self,
|
||||
*,
|
||||
slot0_datetime: DateTime,
|
||||
earliest_slot: int,
|
||||
horizon_end_slot: int,
|
||||
cycle_index: Optional[int] = None,
|
||||
) -> list[int]:
|
||||
"""Return the sorted absolute start slots at which a full run is allowed.
|
||||
|
||||
A start slot ``s`` is allowed when the complete run fits the optimization
|
||||
horizon, both absolute bounds and (if configured) a single allowed time
|
||||
window:
|
||||
|
||||
- ``earliest_slot <= s`` and ``s + run_slots <= horizon_end_slot``
|
||||
- with ``earliest_start_datetime`` set, the run starts at or after it
|
||||
- with ``deadline_datetime`` set, the run *ends* at or before it
|
||||
- with ``time_windows`` set, the run's whole occupied span starting at
|
||||
``s`` is contained in one window (respecting weekday/date constraints)
|
||||
|
||||
When a deadline leaves no start at all and the policy is
|
||||
``BEST_EFFORT``, the deadline is dropped and only the earliest still
|
||||
possible start is offered (a warning is logged and ``deadline_relaxed``
|
||||
is set). Multiple cycles retain all relaxed choices for joint
|
||||
earliest-start repair by the optimizer, respecting per-cycle windows
|
||||
and minimum gaps.
|
||||
|
||||
No snapping is performed: every returned slot is a genuinely valid start.
|
||||
|
||||
Args:
|
||||
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||
earliest_slot: First slot the optimizer may schedule at ("now").
|
||||
horizon_end_slot: Exclusive upper bound; a run must end at or before.
|
||||
cycle_index: Global configured cycle index, independent of completed cycles.
|
||||
|
||||
Returns:
|
||||
Sorted list of allowed absolute start slots (may be empty).
|
||||
"""
|
||||
self.deadline_relaxed = False
|
||||
allowed = self._allowed_start_slots(
|
||||
slot0_datetime=slot0_datetime,
|
||||
earliest_slot=earliest_slot,
|
||||
horizon_end_slot=horizon_end_slot,
|
||||
apply_deadline=True,
|
||||
cycle_index=cycle_index,
|
||||
)
|
||||
if (
|
||||
allowed
|
||||
or self.deadline_datetime is None
|
||||
or self.deadline_policy == ConsumerDeadlinePolicy.STRICT
|
||||
):
|
||||
return allowed
|
||||
|
||||
relaxed = self._allowed_start_slots(
|
||||
slot0_datetime=slot0_datetime,
|
||||
earliest_slot=earliest_slot,
|
||||
horizon_end_slot=horizon_end_slot,
|
||||
apply_deadline=False,
|
||||
cycle_index=cycle_index,
|
||||
)
|
||||
if not relaxed:
|
||||
return relaxed
|
||||
self.deadline_relaxed = True
|
||||
# Keep only the earliest possible start: the deadline is already missed,
|
||||
# so the run is scheduled as soon as possible rather than as cheap as
|
||||
# possible. Multiple cycles retain choices so the optimizer can find
|
||||
# the earliest joint schedule that respects idle gaps.
|
||||
earliest = relaxed[:1] if self.num_cycles == 1 else relaxed
|
||||
logger.warning(
|
||||
"Home appliance '{}': deadline {} can not be met - running as early as "
|
||||
"possible instead (BEST_EFFORT). Run ends {}.",
|
||||
self.device_id,
|
||||
self.deadline_datetime,
|
||||
self.run_end_datetime(earliest[0], slot0_datetime),
|
||||
)
|
||||
return earliest
|
||||
|
||||
def _allowed_start_slots(
|
||||
self,
|
||||
*,
|
||||
slot0_datetime: DateTime,
|
||||
earliest_slot: int,
|
||||
horizon_end_slot: int,
|
||||
cycle_index: Optional[int] = None,
|
||||
apply_deadline: bool,
|
||||
) -> list[int]:
|
||||
"""Compute the allowed start slots for one set of constraints.
|
||||
|
||||
Args:
|
||||
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||
earliest_slot: First slot the optimizer may schedule at ("now").
|
||||
horizon_end_slot: Exclusive upper bound; a run must end at or before.
|
||||
cycle_index: Global configured cycle index, independent of completed cycles.
|
||||
apply_deadline: Whether ``deadline_datetime`` restricts the run end.
|
||||
|
||||
Returns:
|
||||
Sorted list of allowed absolute start slots (may be empty).
|
||||
"""
|
||||
run_slots = self.run_slots
|
||||
if run_slots <= 0:
|
||||
return []
|
||||
|
||||
first_start = max(earliest_slot, 0)
|
||||
if self.earliest_start_datetime is not None:
|
||||
first_start = max(
|
||||
first_start,
|
||||
self._slot_offset(self.earliest_start_datetime, slot0_datetime, round_up=True),
|
||||
)
|
||||
|
||||
end_bound = min(horizon_end_slot, self.total_slots)
|
||||
if apply_deadline and self.deadline_datetime is not None:
|
||||
end_bound = min(
|
||||
end_bound,
|
||||
self._slot_offset(self.deadline_datetime, slot0_datetime, round_up=False),
|
||||
)
|
||||
|
||||
last_start = end_bound - run_slots
|
||||
if last_start < first_start:
|
||||
return []
|
||||
|
||||
if cycle_index is not None and not 0 <= cycle_index < self.num_cycles:
|
||||
raise ValueError("Cycle index is outside the configured cycle range.")
|
||||
cycle_windows = [
|
||||
window
|
||||
for window in (
|
||||
self.parameters.time_windows.windows if self.parameters.time_windows else []
|
||||
)
|
||||
if window.value is not None and int(window.value) == cycle_index
|
||||
]
|
||||
|
||||
run_duration = to_duration(f"{run_slots * self.slot_interval_seconds} seconds")
|
||||
allowed: list[int] = []
|
||||
for slot in range(first_start, last_start + 1):
|
||||
start_dt = slot0_datetime.add(seconds=slot * self.slot_interval_seconds)
|
||||
if (
|
||||
self.time_windows is None
|
||||
or self._windows_allow_run(self.time_windows.windows, start_dt, run_duration)
|
||||
) and (
|
||||
not cycle_windows
|
||||
or self._windows_allow_run(cycle_windows, start_dt, run_duration, merge=True)
|
||||
):
|
||||
allowed.append(slot)
|
||||
return allowed
|
||||
|
||||
@staticmethod
|
||||
def _windows_allow_run(
|
||||
windows: Sequence[TimeWindow], start: DateTime, duration: Duration, *, merge: bool = False
|
||||
) -> bool:
|
||||
"""Check full coverage including windows anchored on previous local dates.
|
||||
|
||||
Date and weekday restrictions belong to the window's opening day.
|
||||
Per-cycle windows retain their existing union semantics; shared windows
|
||||
require one complete containing occurrence.
|
||||
"""
|
||||
intervals: list[tuple[DateTime, DateTime]] = []
|
||||
run_end = start + duration
|
||||
for window in windows:
|
||||
days_back = math.ceil(window.duration.total_seconds() / 86400) + 1
|
||||
for offset in range(days_back + 1):
|
||||
anchor = start.subtract(days=offset)
|
||||
if window.date is not None and anchor.date() != window.date:
|
||||
continue
|
||||
if window.day_of_week is not None and anchor.day_of_week != window.day_of_week:
|
||||
continue
|
||||
opening, closing = window._window_start_end(anchor)
|
||||
if opening <= start and run_end <= closing:
|
||||
return True
|
||||
if merge and opening < run_end and closing > start:
|
||||
intervals.append((opening, closing))
|
||||
covered_until = start
|
||||
for opening, closing in sorted(intervals):
|
||||
if opening > covered_until:
|
||||
break
|
||||
covered_until = max(covered_until, closing)
|
||||
if covered_until >= run_end:
|
||||
return True
|
||||
return False
|
||||
|
||||
def run_end_datetime(self, start_slot: int, slot0_datetime: DateTime) -> DateTime:
|
||||
"""Absolute local moment at which a run started at ``start_slot`` finishes.
|
||||
|
||||
Args:
|
||||
start_slot: Absolute start slot of the run.
|
||||
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||
|
||||
Returns:
|
||||
End datetime of the run (exclusive, i.e. the first free moment).
|
||||
"""
|
||||
return slot0_datetime.add(
|
||||
seconds=(start_slot + self.run_slots) * self.slot_interval_seconds
|
||||
)
|
||||
|
||||
def deadline_missed(self, starts: list[int], slot0_datetime: DateTime) -> bool:
|
||||
"""Whether the scheduled runs violate the configured deadline.
|
||||
|
||||
Without a deadline nothing can be missed. With one, a consumer that was
|
||||
not scheduled at all, or whose run ends after the deadline (a relaxed
|
||||
BEST_EFFORT deadline), counts as missed.
|
||||
|
||||
Args:
|
||||
starts: Absolute start slots of the scheduled runs.
|
||||
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||
|
||||
Returns:
|
||||
True if the deadline is set and not met.
|
||||
"""
|
||||
if self.deadline_datetime is None:
|
||||
return False
|
||||
if not starts:
|
||||
return True
|
||||
timezone = slot0_datetime.timezone
|
||||
if timezone is None:
|
||||
raise ValueError("The optimization slot origin must have a timezone.")
|
||||
deadline = self.deadline_datetime.in_timezone(timezone)
|
||||
return any(self.run_end_datetime(start, slot0_datetime) > deadline for start in starts)
|
||||
|
||||
def build_load_curve(self, starts: list[int]) -> None:
|
||||
"""Place the resampled run energy at each decoded start slot.
|
||||
|
||||
Multiple runs may overlap; their per-slot energies are summed.
|
||||
|
||||
Args:
|
||||
starts: Absolute start slots of the scheduled runs.
|
||||
"""
|
||||
if any(start < 0 or start + self.run_slots > self.total_slots for start in starts):
|
||||
raise ValueError("A complete appliance run must fit inside the slot horizon.")
|
||||
self.start_hours = list(starts)
|
||||
self._build_load_curve()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Setup
|
||||
# ------------------------------------------------------------------
|
||||
@@ -257,9 +728,9 @@ class HomeAppliance:
|
||||
microsecond=0,
|
||||
)
|
||||
end_datetime = start_datetime.add(
|
||||
hours=self.prediction_hours,
|
||||
seconds=self.prediction_hours * self.slot_interval_seconds,
|
||||
)
|
||||
interval = to_duration("1 hour")
|
||||
interval = to_duration(self.slot_interval_seconds)
|
||||
|
||||
self._build_cycle_start_allowed(
|
||||
self.parameters.time_windows,
|
||||
@@ -293,7 +764,7 @@ class HomeAppliance:
|
||||
|
||||
max_start = max(
|
||||
0,
|
||||
self.prediction_hours - self.duration_h,
|
||||
self.prediction_hours - self.run_slots,
|
||||
)
|
||||
|
||||
# The matrix tells us which individual *steps* are inside
|
||||
@@ -348,7 +819,7 @@ class HomeAppliance:
|
||||
horizon = len(window_steps)
|
||||
max_start = max(
|
||||
0,
|
||||
horizon - self.duration_h,
|
||||
horizon - self.run_slots,
|
||||
)
|
||||
|
||||
allowed = np.zeros(
|
||||
@@ -356,15 +827,15 @@ class HomeAppliance:
|
||||
dtype=bool,
|
||||
)
|
||||
|
||||
if self.duration_h > horizon:
|
||||
if self.run_slots > horizon:
|
||||
return allowed
|
||||
|
||||
# Rolling sum of `duration_h` consecutive steps, aligned so
|
||||
# that window_sums[s] == sum(window_steps[s : s + duration_h]).
|
||||
cumulative = np.concatenate(([0.0], np.cumsum(window_steps)))
|
||||
window_sums = cumulative[self.duration_h :] - cumulative[: -self.duration_h]
|
||||
window_sums = cumulative[self.run_slots :] - cumulative[: -self.run_slots]
|
||||
|
||||
allowed[: max_start + 1] = window_sums[: max_start + 1] == float(self.duration_h)
|
||||
allowed[: max_start + 1] = window_sums[: max_start + 1] == float(self.run_slots)
|
||||
|
||||
return allowed
|
||||
|
||||
@@ -404,7 +875,7 @@ class HomeAppliance:
|
||||
|
||||
max_start = max(
|
||||
0,
|
||||
self.prediction_hours - self.duration_h,
|
||||
self.prediction_hours - self.run_slots,
|
||||
)
|
||||
|
||||
# 1. Round and clip.
|
||||
@@ -442,7 +913,7 @@ class HomeAppliance:
|
||||
# 4. Enforce duration + minimum idle gap. Each cycle is
|
||||
# pushed forward, if needed, to the next start allowed by
|
||||
# its *own* window.
|
||||
min_next_start = self.duration_h + self.min_cycle_gap_h
|
||||
min_next_start = self.run_slots + math.ceil(self.min_cycle_gap_h / self.slot_duration_h)
|
||||
|
||||
final_starts = [repaired[0][0]]
|
||||
|
||||
@@ -582,25 +1053,17 @@ class HomeAppliance:
|
||||
"""Builds the load curve from all scheduled cycles."""
|
||||
self.reset_load_curve()
|
||||
|
||||
power_per_hour = self.consumption_wh / self.duration_h
|
||||
|
||||
for start_hour in self.start_hours:
|
||||
if start_hour >= self.prediction_hours:
|
||||
continue
|
||||
|
||||
end_hour = min(
|
||||
start_hour + self.duration_h,
|
||||
self.prediction_hours,
|
||||
)
|
||||
|
||||
self.load_curve[start_hour:end_hour] += power_per_hour
|
||||
for start in self.start_hours:
|
||||
length = min(self.run_slots, self.total_slots - start)
|
||||
if 0 <= start and length > 0:
|
||||
self.load_curve[start : start + length] += self.run_energy_wh[:length]
|
||||
|
||||
def reset_load_curve(self) -> None:
|
||||
"""Resets the load curve to all zeros."""
|
||||
self.load_curve = np.zeros(self.prediction_hours)
|
||||
|
||||
def get_load_curve(self) -> np.ndarray:
|
||||
"""Returns the current hourly load curve, in watts."""
|
||||
"""Returns the current per-slot load curve, in watt-hours."""
|
||||
return self.load_curve
|
||||
|
||||
def get_load_for_hour(self, hour: int) -> float:
|
||||
@@ -610,7 +1073,7 @@ class HomeAppliance:
|
||||
hour: Hour of the prediction horizon to look up.
|
||||
|
||||
Returns:
|
||||
The load, in watts, at ``hour``.
|
||||
The energy, in watt-hours, at slot ``hour``.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``hour`` is outside
|
||||
|
||||
@@ -10,11 +10,13 @@ import numpy as np
|
||||
from numpydantic import NDArray, Shape
|
||||
from pydantic import Field, computed_field, field_validator, model_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import ConfigScope
|
||||
from akkudoktoreos.devices.settings.devicebasesettings import DevicesBaseSettings
|
||||
from akkudoktoreos.measurement.batterycapacity import (
|
||||
BatteryCapacityEstimate,
|
||||
BatteryCapacityEstimationSettings,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from akkudoktoreos.devices.genetic0.genetic0battery import (
|
||||
@@ -175,6 +177,41 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
||||
|
||||
return arr.tolist()
|
||||
|
||||
min_soc_deadline_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Absolute moment by which 'min_soc_percentage' has to be "
|
||||
"reached (departure time). A date time without timezone is read "
|
||||
"as local time. None means end of the optimization horizon."
|
||||
),
|
||||
"examples": [None, "2026-07-16T07:00:00+02:00"],
|
||||
},
|
||||
)
|
||||
min_soc_max_duration_h: Optional[float] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
allow_inf_nan=False,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Maximum time from the start of the optimization until "
|
||||
"'min_soc_percentage' has to be reached [h]. Combined with "
|
||||
"'min_soc_deadline_datetime' the earlier of the two applies."
|
||||
),
|
||||
"examples": [None, 6.0],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("min_soc_deadline_datetime", mode="before")
|
||||
@classmethod
|
||||
def transform_deadline_to_datetime(cls, value: Any) -> Optional[DateTime]:
|
||||
"""Accept the usual date time representations, naive input is local time."""
|
||||
if value is None:
|
||||
return None
|
||||
return to_datetime(value)
|
||||
|
||||
def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters":
|
||||
"""Return SolarPanelBatteryParameters for the GENETIC optimizer."""
|
||||
from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters
|
||||
@@ -205,6 +242,8 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
||||
max_charge_power_w=self.max_charge_power_w,
|
||||
min_soc_percentage=self.min_soc_percentage,
|
||||
max_soc_percentage=self.max_soc_percentage,
|
||||
min_soc_deadline_datetime=self.min_soc_deadline_datetime,
|
||||
min_soc_max_duration_h=self.min_soc_max_duration_h,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
Note: Used for the GENETIC and GENETIC0 algorithm.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Any, Optional, Self
|
||||
|
||||
from pydantic import Field, computed_field, model_validator
|
||||
from pydantic import Field, computed_field, field_validator, model_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import ConfigScope, CycleTimeWindowSequence
|
||||
from akkudoktoreos.config.configabc import (
|
||||
ConfigScope,
|
||||
CycleTimeWindowSequence,
|
||||
TimeWindowSequence,
|
||||
)
|
||||
from akkudoktoreos.devices.devicesabc import (
|
||||
ConsumerDeadlinePolicy,
|
||||
ConsumerScheduleMode,
|
||||
validate_home_appliance_load_definition,
|
||||
)
|
||||
from akkudoktoreos.devices.settings.devicebasesettings import DevicesBaseSettings
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
|
||||
@@ -105,7 +115,7 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
|
||||
|
||||
"""
|
||||
|
||||
consumption_wh: int = Field(
|
||||
consumption_wh: Optional[int] = Field(
|
||||
default=3000,
|
||||
gt=0,
|
||||
json_schema_extra={
|
||||
@@ -114,7 +124,7 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
|
||||
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
|
||||
},
|
||||
)
|
||||
duration_h: int = Field(
|
||||
duration_h: Optional[int] = Field(
|
||||
default=3,
|
||||
gt=0,
|
||||
le=24,
|
||||
@@ -193,6 +203,133 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
|
||||
},
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _profile_replaces_flat_defaults(cls, value: Any) -> Any:
|
||||
"""Keep historical flat defaults unless an explicit profile is supplied."""
|
||||
if isinstance(value, dict) and value.get("load_profile_power_w") is not None:
|
||||
value = dict(value)
|
||||
value.setdefault("consumption_wh", None)
|
||||
value.setdefault("duration_h", None)
|
||||
return value
|
||||
|
||||
load_profile_power_w: Optional[list[float]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Explicit load profile describing a single complete run as a "
|
||||
"sequence of non-negative power values in watts. Each value "
|
||||
"covers 'load_profile_interval_seconds'. Mutually exclusive with "
|
||||
"consumption_wh/duration_h."
|
||||
),
|
||||
"examples": [[200.0, 2000.0, 1800.0, 100.0]],
|
||||
},
|
||||
)
|
||||
load_profile_interval_seconds: Optional[int] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Duration of one 'load_profile_power_w' step in seconds. Defaults "
|
||||
"to the configured optimization interval when a profile is given."
|
||||
),
|
||||
"examples": [900, 3600],
|
||||
},
|
||||
)
|
||||
schedule_mode: ConsumerScheduleMode = Field(
|
||||
default=ConsumerScheduleMode.ONCE,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Scheduling mode: ONCE (a single run within the horizon) or DAILY "
|
||||
"(one run per local calendar day with a feasible full run)."
|
||||
),
|
||||
"examples": ["ONCE", "DAILY"],
|
||||
},
|
||||
)
|
||||
time_windows: Optional[TimeWindowSequence] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": "List of allowed time windows. Defaults to optimization general time window.",
|
||||
"examples": [
|
||||
[
|
||||
{"start_time": "10:00", "duration": "3 hours"},
|
||||
],
|
||||
],
|
||||
},
|
||||
)
|
||||
earliest_start_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Absolute earliest moment the run may start. Starts before it are "
|
||||
"dropped, in addition to 'time_windows' and the horizon. A date "
|
||||
"time without timezone is read as local time. This bound is never "
|
||||
"relaxed."
|
||||
),
|
||||
"examples": [None, "2026-07-15T20:00:00+02:00"],
|
||||
},
|
||||
)
|
||||
deadline_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Absolute deadline: the complete run must have *finished* at or "
|
||||
"before this moment (e.g. end of the day, or 03:00 tonight). A "
|
||||
"date time without timezone is read as local time. See "
|
||||
"'deadline_policy' for what happens when no start can meet it."
|
||||
),
|
||||
"examples": [None, "2026-07-16T03:00:00+02:00"],
|
||||
},
|
||||
)
|
||||
deadline_policy: ConsumerDeadlinePolicy = Field(
|
||||
default=ConsumerDeadlinePolicy.BEST_EFFORT,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"What to do when 'deadline_datetime' cannot be met: BEST_EFFORT "
|
||||
"runs as early as possible instead (warning logged), STRICT keeps "
|
||||
"the deadline (a ONCE consumer then fails the optimization)."
|
||||
),
|
||||
"examples": ["BEST_EFFORT", "STRICT"],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("earliest_start_datetime", "deadline_datetime", mode="before")
|
||||
@classmethod
|
||||
def transform_to_datetime(cls, value: Any) -> Optional[DateTime]:
|
||||
"""Accept the usual date time representations, naive input is local time."""
|
||||
if value is None:
|
||||
return None
|
||||
return to_datetime(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_load_definition(self) -> Self:
|
||||
"""Ensure exactly one complete, valid load definition is provided."""
|
||||
validate_home_appliance_load_definition(
|
||||
load_profile_power_w=self.load_profile_power_w,
|
||||
load_profile_interval_seconds=self.load_profile_interval_seconds,
|
||||
consumption_wh=self.consumption_wh,
|
||||
duration_h=self.duration_h,
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_schedule_bounds(self) -> Self:
|
||||
"""Reject an empty scheduling interval."""
|
||||
if self.earliest_start_datetime is not None and self.deadline_datetime is not None:
|
||||
if compare_datetimes(self.deadline_datetime, self.earliest_start_datetime).le:
|
||||
raise ValueError(
|
||||
f"deadline_datetime {self.deadline_datetime} must be after "
|
||||
f"earliest_start_datetime {self.earliest_start_datetime}."
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_num_cycles_specified(self) -> "HomeApplianceCommonSettings":
|
||||
"""Require num_cycles when windows are not provided."""
|
||||
@@ -231,6 +368,13 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
|
||||
num_cycles=self.effective_num_cycles,
|
||||
min_cycle_gap_h=self.min_cycle_gap_h,
|
||||
time_windows=self.cycle_time_windows,
|
||||
shared_time_windows=self.time_windows,
|
||||
load_profile_power_w=self.load_profile_power_w,
|
||||
load_profile_interval_seconds=self.load_profile_interval_seconds,
|
||||
schedule_mode=self.schedule_mode,
|
||||
earliest_start_datetime=self.earliest_start_datetime,
|
||||
deadline_datetime=self.deadline_datetime,
|
||||
deadline_policy=self.deadline_policy,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -243,6 +387,10 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
|
||||
Genetic0HomeApplianceParameters,
|
||||
)
|
||||
|
||||
if self.load_profile_power_w is not None:
|
||||
raise ValueError("Explicit appliance load profiles require the GENETIC optimizer.")
|
||||
if self.consumption_wh is None or self.duration_h is None:
|
||||
raise ValueError("GENETIC0 requires a flat appliance load definition.")
|
||||
return Genetic0HomeApplianceParameters(
|
||||
device_id=self.device_id,
|
||||
consumption_wh=self.consumption_wh,
|
||||
@@ -254,5 +402,4 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
|
||||
@property
|
||||
def measurement_keys(self) -> Optional[list[str]]:
|
||||
"""Measurement keys for the home appliance stati that are measurements."""
|
||||
keys: list[str] = []
|
||||
return keys
|
||||
return [self.cycles_completed_measurement_key or f"{self.device_id}.cycles_completed"]
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Configuration-owned GENETIC requests with fresh runtime observations."""
|
||||
|
||||
import math
|
||||
from typing import Annotated, Any, Optional
|
||||
|
||||
from pydantic import AliasChoices, Field, field_validator
|
||||
|
||||
from akkudoktoreos.core.coreabc import (
|
||||
ConfigMixin,
|
||||
MeasurementMixin,
|
||||
PredictionMixin,
|
||||
get_ems,
|
||||
)
|
||||
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
|
||||
|
||||
|
||||
class RuntimeForecasts(GeneticParametersBaseModel):
|
||||
"""External series from local midnight, in slot Wh and currency per Wh.
|
||||
|
||||
The interval is configured in optimization.genetic.interval_sec. Omitted
|
||||
series are read from the configured providers, preserving their raw coverage.
|
||||
"""
|
||||
|
||||
pv_forecast_wh: Optional[list[float]] = Field(
|
||||
default=None, validation_alias=AliasChoices("pv_forecast_wh", "pv_prognose_wh")
|
||||
)
|
||||
total_load: Optional[list[float]] = Field(
|
||||
default=None, validation_alias=AliasChoices("total_load", "gesamtlast")
|
||||
)
|
||||
electricity_price_per_wh: Optional[list[float]] = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("electricity_price_per_wh", "strompreis_euro_pro_wh"),
|
||||
)
|
||||
feed_in_tariff_per_wh: Optional[list[float]] = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("feed_in_tariff_per_wh", "einspeiseverguetung_euro_pro_wh"),
|
||||
)
|
||||
temperature_forecast: Optional[list[Optional[float]]] = None
|
||||
|
||||
|
||||
class ConfigOptimizationRequest(
|
||||
ConfigMixin, MeasurementMixin, PredictionMixin, GeneticParametersBaseModel
|
||||
):
|
||||
"""Supply observations while hardware, tariffs and scheduling remain in config."""
|
||||
|
||||
soc: dict[str, Annotated[int, Field(ge=0, le=100)]] = Field(default_factory=dict)
|
||||
forecasts: RuntimeForecasts = Field(default_factory=RuntimeForecasts)
|
||||
start_solution: Optional[list[float]] = None
|
||||
start_solution_datetime: Optional[DateTime] = None
|
||||
|
||||
@field_validator("start_solution_datetime", mode="before")
|
||||
@classmethod
|
||||
def validate_start_solution_datetime(cls, value: Any) -> Optional[DateTime]:
|
||||
"""Parse a previous-plan timestamp while retaining its explicit timezone."""
|
||||
return GeneticOptimizationParameters.transform_start_solution_datetime(value)
|
||||
|
||||
async def resolve(self) -> GeneticOptimizationParameters:
|
||||
"""Resolve inside the EMS lock after its slot start has been established."""
|
||||
config = self.config
|
||||
settings = config.optimization.genetic
|
||||
config.validate_optimization_horizons()
|
||||
ems = get_ems()
|
||||
start = ems.start_datetime
|
||||
observation_time = ems.observation_datetime
|
||||
devices = config.devices
|
||||
groups: dict[str, list[Any]] = {}
|
||||
for name in ("batteries", "electric_vehicles", "inverters", "home_appliances"):
|
||||
entries = list((getattr(devices, name) or {}).values())
|
||||
maximum = getattr(devices, "max_" + name)
|
||||
if maximum is not None and len(entries) > maximum:
|
||||
raise ValueError(f"devices.{name} exceeds configured maximum {maximum}.")
|
||||
if name != "home_appliances" and len(entries) > 1:
|
||||
raise ValueError(f"GENETIC supports at most one device in devices.{name}.")
|
||||
groups[name] = entries
|
||||
ids = [device.device_id for entries in groups.values() for device in entries]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("Configured device IDs must be unique across device groups.")
|
||||
storage = groups["batteries"] + groups["electric_vehicles"]
|
||||
unknown = set(self.soc) - {device.device_id for device in storage}
|
||||
if unknown:
|
||||
raise ValueError(f"SoC supplied for unconfigured devices: {sorted(unknown)}.")
|
||||
|
||||
async def state(device: BatteriesCommonSettings) -> int:
|
||||
if device.device_id in self.soc:
|
||||
return self.soc[device.device_id]
|
||||
try:
|
||||
dates, values = await self.measurement.key_to_lists(
|
||||
key=device.measurement_key_soc_factor,
|
||||
start_datetime=observation_time.subtract(
|
||||
seconds=settings.measurement_max_age_seconds
|
||||
),
|
||||
end_datetime=observation_time.add(seconds=1),
|
||||
dropna=False,
|
||||
)
|
||||
samples = [
|
||||
(date, value)
|
||||
for date, value in zip(dates, values)
|
||||
if date.timestamp() <= observation_time.timestamp()
|
||||
]
|
||||
date, value = max(samples, key=lambda sample: sample[0].timestamp())
|
||||
if (
|
||||
observation_time.timestamp() - date.timestamp()
|
||||
> settings.measurement_max_age_seconds
|
||||
or value is None
|
||||
or not math.isfinite(value)
|
||||
or not 0 <= value <= 1
|
||||
):
|
||||
raise ValueError("Invalid or stale SoC factor")
|
||||
return int(value * 100)
|
||||
except (ValueError, KeyError) as exc:
|
||||
raise ValueError(
|
||||
f"Fresh SoC missing for {device.device_id}; supply soc or a measurement."
|
||||
) from exc
|
||||
|
||||
battery = None
|
||||
if groups["batteries"]:
|
||||
device = groups["batteries"][0]
|
||||
battery = device.to_genetic_pv_bat_param()
|
||||
battery.initial_soc_percentage = await state(device)
|
||||
ev = None
|
||||
if groups["electric_vehicles"]:
|
||||
device = groups["electric_vehicles"][0]
|
||||
ev = device.to_genetic_ev_bat_param()
|
||||
ev.initial_soc_percentage = await state(device)
|
||||
inverter = None
|
||||
if groups["inverters"]:
|
||||
device = groups["inverters"][0]
|
||||
if device.battery_id != (battery.device_id if battery else None):
|
||||
raise ValueError("Inverter battery_id must match the configured battery.")
|
||||
inverter = device.to_genetic_param()
|
||||
if inverter is None:
|
||||
raise ValueError("Configure an inverter to model PV and grid energy flows.")
|
||||
appliances = [device.to_genetic_param() for device in groups["home_appliances"]]
|
||||
for device, appliance in zip(groups["home_appliances"], appliances):
|
||||
key = device.cycles_completed_measurement_key or f"{device.device_id}.cycles_completed"
|
||||
try:
|
||||
dates, counts = await self.measurement.key_to_lists(
|
||||
key=key,
|
||||
start_datetime=observation_time.start_of("day"),
|
||||
end_datetime=observation_time.add(seconds=1),
|
||||
dropna=False,
|
||||
)
|
||||
except KeyError:
|
||||
continue
|
||||
samples = [
|
||||
(date, count)
|
||||
for date, count in zip(dates, counts)
|
||||
if date.timestamp() <= observation_time.timestamp()
|
||||
]
|
||||
if not samples:
|
||||
continue
|
||||
count = max(samples, key=lambda item: item[0].timestamp())[1]
|
||||
if (
|
||||
count is None
|
||||
or not math.isfinite(count)
|
||||
or int(count) != count
|
||||
or not 0 <= count <= appliance.num_cycles
|
||||
):
|
||||
raise ValueError(f"Invalid completed cycle count for {device.device_id}.")
|
||||
appliance.completed_cycles = int(count)
|
||||
|
||||
origin = start.start_of("day")
|
||||
if config.prediction.hours is None or config.prediction.hours <= 0:
|
||||
raise ValueError("Configure a positive prediction horizon.")
|
||||
end = start.add(hours=config.prediction.hours)
|
||||
interval = to_duration(settings.interval_sec)
|
||||
updated = False
|
||||
|
||||
async def forecast(
|
||||
supplied: Optional[list[float]], key: str, energy: bool = False
|
||||
) -> list[float]:
|
||||
nonlocal updated
|
||||
if supplied is not None:
|
||||
return list(supplied)
|
||||
if not updated:
|
||||
await self.prediction.update_data()
|
||||
updated = True
|
||||
values = await bounded_forecast_array(
|
||||
self.prediction, key=key, start_datetime=origin, end_datetime=end, interval=interval
|
||||
)
|
||||
if energy:
|
||||
values = values * (settings.interval_sec / 3600)
|
||||
return values.tolist()
|
||||
|
||||
supplied = self.forecasts
|
||||
pv = await forecast(supplied.pv_forecast_wh, "pvforecast_ac_power", energy=True)
|
||||
load = await forecast(supplied.total_load, "loadforecast_power_w", energy=True)
|
||||
prices = await forecast(supplied.electricity_price_per_wh, "elecprice_marketprice_wh")
|
||||
tariffs = await forecast(supplied.feed_in_tariff_per_wh, "feed_in_tariff_wh")
|
||||
length = min(len(pv), len(load), len(prices), len(tariffs))
|
||||
if not length:
|
||||
raise ValueError("Forecast series must be nonempty.")
|
||||
pv, load, prices, tariffs = (values[:length] for values in (pv, load, prices, tariffs))
|
||||
first = int((start - origin).total_seconds() / settings.interval_sec)
|
||||
last = first + settings.horizon
|
||||
if settings.horizon <= 0 or len(pv) < last:
|
||||
raise ValueError("Forecast does not cover the positive GENETIC control horizon.")
|
||||
for key, values in (
|
||||
("PV", pv),
|
||||
("load", load),
|
||||
("prices", prices),
|
||||
("feed-in tariff", tariffs),
|
||||
):
|
||||
if not all(math.isfinite(value) for value in values[first:last]):
|
||||
raise ValueError(f"Missing or invalid {key} within the control horizon.")
|
||||
if any(value < 0 for values in (pv, load) for value in values[first:last]):
|
||||
raise ValueError("PV and load energy must be nonnegative.")
|
||||
previous = ems.genetic_solution()
|
||||
warm_start = self.start_solution
|
||||
warm_start_time = self.start_solution_datetime
|
||||
if warm_start is None and previous is not None:
|
||||
warm_start = previous.start_solution
|
||||
warm_start_time = previous.start_solution_datetime
|
||||
return GeneticOptimizationParameters(
|
||||
forecast_interval_seconds=settings.interval_sec,
|
||||
ems=GeneticEnergyManagementParameters(
|
||||
pv_forecast_wh=pv,
|
||||
total_load=load,
|
||||
electricity_price_per_wh=prices,
|
||||
feed_in_tariff_per_wh=tariffs,
|
||||
price_per_wh_battery=settings.terminal_value_euro_per_kwh / 1000,
|
||||
),
|
||||
pv_battery=battery,
|
||||
ev=ev,
|
||||
inverter=inverter,
|
||||
home_appliances=appliances,
|
||||
temperature_forecast=(
|
||||
(supplied.temperature_forecast[:length] + [None] * length)[:length]
|
||||
if supplied.temperature_forecast is not None
|
||||
else None
|
||||
),
|
||||
start_solution=warm_start,
|
||||
start_solution_datetime=warm_start_time,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Read forecast interval averages without filling gaps or extrapolating the tail."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, Duration
|
||||
|
||||
|
||||
async def bounded_forecast_array(
|
||||
prediction: Any,
|
||||
*,
|
||||
key: str,
|
||||
start_datetime: DateTime,
|
||||
end_datetime: DateTime,
|
||||
interval: Duration,
|
||||
) -> np.ndarray:
|
||||
"""Integrate stored interval averages only across completely covered target slots.
|
||||
|
||||
Source timestamps are interval starts. Infer their smallest positive cadence,
|
||||
capped at one hour. A missing source interval, explicit NaN or the end of the
|
||||
available forecast remains missing. UTC elapsed time handles DST transitions.
|
||||
The returned values retain the input units; callers convert W to slot Wh once.
|
||||
"""
|
||||
seconds = int(interval.total_seconds())
|
||||
start = start_datetime.timestamp()
|
||||
end = end_datetime.timestamp()
|
||||
if seconds <= 0 or end <= start or (end - start) % seconds:
|
||||
raise ValueError("Forecast bounds must contain a positive whole number of slots.")
|
||||
result = np.full(int((end - start) / seconds), np.nan)
|
||||
try:
|
||||
series = await prediction.key_to_raw_series(key=key, dropna=False)
|
||||
except KeyError:
|
||||
return result
|
||||
if series.empty:
|
||||
return result
|
||||
series = pd.to_numeric(series, errors="coerce").sort_index()
|
||||
series = series[~series.index.duplicated(keep="last")]
|
||||
stamps = pd.to_datetime(series.index, utc=True).as_unit("ns").asi8 / 1e9
|
||||
values = series.to_numpy(dtype=float)
|
||||
differences = np.diff(stamps)
|
||||
cadence = min(3600.0, float(np.min(differences))) if len(differences) else 3600.0
|
||||
for slot in range(len(result)):
|
||||
left = start + slot * seconds
|
||||
right = left + seconds
|
||||
position = max(0, int(np.searchsorted(stamps, left, side="right")) - 1)
|
||||
covered = 0.0
|
||||
weighted = 0.0
|
||||
while position < len(stamps) and stamps[position] < right:
|
||||
source_end = stamps[position] + cadence
|
||||
if position + 1 < len(stamps):
|
||||
source_end = min(source_end, stamps[position + 1])
|
||||
overlap = max(0.0, min(right, source_end) - max(left, stamps[position]))
|
||||
if overlap and np.isfinite(values[position]):
|
||||
covered += overlap
|
||||
weighted += (overlap / seconds) * values[position]
|
||||
position += 1
|
||||
if abs(covered - seconds) < 1e-6:
|
||||
result[slot] = weighted
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,9 @@ It also provides a method to assemble these parameters from predictions,
|
||||
forecasts, and fallback defaults, preparing them for optimization runs.
|
||||
"""
|
||||
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
@@ -26,7 +26,6 @@ from akkudoktoreos.core.coreabc import (
|
||||
ConfigMixin,
|
||||
MeasurementMixin,
|
||||
PredictionMixin,
|
||||
get_ems,
|
||||
)
|
||||
from akkudoktoreos.devices.genetic.battery import (
|
||||
ElectricVehicleParameters,
|
||||
@@ -35,7 +34,7 @@ from akkudoktoreos.devices.genetic.battery import (
|
||||
from akkudoktoreos.devices.genetic.homeappliance import HomeApplianceParameters
|
||||
from akkudoktoreos.devices.genetic.inverter import InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
||||
|
||||
# Do not import directly from akkudoktoreos.core.coreabc
|
||||
# EnergyManagementSystemMixin - Creates circular dependency with ems.py
|
||||
@@ -50,7 +49,7 @@ class GeneticEnergyManagementParameters(GeneticParametersBaseModel):
|
||||
pv_forecast_wh: list[float] = Field(
|
||||
validation_alias=AliasChoices("pv_forecast_wh", "pv_prognose_wh"),
|
||||
json_schema_extra={
|
||||
"description": "An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
|
||||
"description": "An array of floats representing the forecasted photovoltaic energy in watt-hours per slot for different time intervals."
|
||||
},
|
||||
)
|
||||
electricity_price_per_wh: list[float] = Field(
|
||||
@@ -74,7 +73,7 @@ class GeneticEnergyManagementParameters(GeneticParametersBaseModel):
|
||||
total_load: list[float] = Field(
|
||||
validation_alias=AliasChoices("total_load", "gesamtlast"),
|
||||
json_schema_extra={
|
||||
"description": "An array of floats representing the total load (consumption) in watts for different time intervals."
|
||||
"description": "An array of floats representing the total load (consumption) in watt-hours per slot for different time intervals."
|
||||
},
|
||||
)
|
||||
|
||||
@@ -105,22 +104,13 @@ class GeneticEnergyManagementParameters(GeneticParametersBaseModel):
|
||||
return self.total_load
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_list_length(self) -> Self:
|
||||
"""Validate that all input lists are of the same length.
|
||||
|
||||
Raises:
|
||||
ValueError: If input list lengths differ.
|
||||
"""
|
||||
pv_forecast_length = len(self.pv_forecast_wh)
|
||||
if (
|
||||
pv_forecast_length != len(self.electricity_price_per_wh)
|
||||
or pv_forecast_length != len(self.total_load)
|
||||
or (
|
||||
isinstance(self.feed_in_tariff_per_wh, list)
|
||||
and pv_forecast_length != len(self.feed_in_tariff_per_wh)
|
||||
)
|
||||
):
|
||||
raise ValueError("Input lists have different lengths")
|
||||
def validate_forecast_arrays(self) -> Self:
|
||||
"""Require forecasts; the optimizer validates control coverage and clips the tail."""
|
||||
arrays = [self.pv_forecast_wh, self.electricity_price_per_wh, self.total_load]
|
||||
if isinstance(self.feed_in_tariff_per_wh, list):
|
||||
arrays.append(self.feed_in_tariff_per_wh)
|
||||
if any(not values for values in arrays):
|
||||
raise ValueError("Forecast arrays must not be empty.")
|
||||
return self
|
||||
|
||||
|
||||
@@ -164,6 +154,91 @@ class GeneticOptimizationParameters(
|
||||
},
|
||||
)
|
||||
|
||||
forecast_interval_seconds: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Input interval: 3600 for hourly, or optimization interval for native slots.",
|
||||
)
|
||||
|
||||
home_appliances: Optional[list[HomeApplianceParameters]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "List of flexible consumers (home appliances) to schedule."
|
||||
},
|
||||
)
|
||||
|
||||
start_solution_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Start of the slot that gene 0 of 'start_solution' controls, as "
|
||||
"returned with the previous solution. The warm start is shifted by "
|
||||
"the slots that have elapsed until this run. Without it, a "
|
||||
"'start_solution' identical to the last solution of this server "
|
||||
"uses that solution's start; any other one is used unshifted."
|
||||
),
|
||||
"examples": [None, "2026-09-14T07:45:00+02:00"],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("forecast_interval_seconds")
|
||||
@classmethod
|
||||
def validate_forecast_interval(cls, value: Optional[int]) -> Optional[int]:
|
||||
if value not in (None, 900, 3600):
|
||||
raise ValueError("forecast_interval_seconds must be 900 or 3600")
|
||||
return value
|
||||
|
||||
@field_validator("start_solution_datetime", mode="before")
|
||||
@classmethod
|
||||
def transform_start_solution_datetime(cls, value: Any) -> Optional[DateTime]:
|
||||
"""Accept the usual date time representations, naive input is local time."""
|
||||
if value is None:
|
||||
return None
|
||||
# A stored run must retain its timezone/fold even when the server host
|
||||
# uses another local timezone. ISO offsets survive JSON round trips.
|
||||
aware = value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
aware = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
pass
|
||||
if isinstance(aware, datetime) and aware.tzinfo is not None:
|
||||
return DateTime.instance(aware)
|
||||
return to_datetime(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_home_appliances(self) -> Self:
|
||||
"""Reject conflicting home appliance definitions.
|
||||
|
||||
The deprecated ``dishwasher`` field and the new ``home_appliances`` list
|
||||
must not be set at the same time; nothing is silently overwritten.
|
||||
Device ids within ``home_appliances`` must be unique.
|
||||
"""
|
||||
# Read the deprecated field via __dict__ to avoid emitting a deprecation
|
||||
# warning on every internal validation.
|
||||
dishwasher = self.__dict__.get("dishwasher")
|
||||
if dishwasher is not None and self.home_appliances is not None:
|
||||
raise ValueError(
|
||||
"Provide either 'home_appliances' or the deprecated 'dishwasher', not both."
|
||||
)
|
||||
appliances = self.home_appliances or []
|
||||
device_ids = [appliance.device_id for appliance in appliances]
|
||||
if len(device_ids) != len(set(device_ids)):
|
||||
raise ValueError("home_appliances device_id values must be unique.")
|
||||
return self
|
||||
|
||||
def resolved_home_appliances(self) -> list[HomeApplianceParameters]:
|
||||
"""Return the effective home appliance list.
|
||||
|
||||
Maps the deprecated single ``dishwasher`` onto a one-element list so the
|
||||
optimizer only ever deals with the list form.
|
||||
"""
|
||||
if self.home_appliances is not None:
|
||||
return list(self.home_appliances)
|
||||
dishwasher = self.__dict__.get("dishwasher")
|
||||
if dishwasher is not None:
|
||||
return [dishwasher]
|
||||
return []
|
||||
|
||||
# Computed fields for backward compatibility (deprecated German names)
|
||||
@computed_field(json_schema_extra={"deprecated": True})
|
||||
def pv_akku(self) -> Optional[SolarPanelBatteryParameters]:
|
||||
@@ -208,620 +283,21 @@ class GeneticOptimizationParameters(
|
||||
|
||||
@classmethod
|
||||
async def prepare(cls) -> "Optional[GeneticOptimizationParameters]":
|
||||
"""Prepare optimization parameters from config, forecast and measurement data.
|
||||
"""Resolve configured devices and fresh forecasts for automatic optimization.
|
||||
|
||||
Fills in values needed for optimization from available configuration, predictions and
|
||||
measurements. If some data is missing, default or demo values are used.
|
||||
|
||||
Parameters start by definition of the genetic algorithm at hour 0 of the actual date
|
||||
(not at start datetime of energy management run)
|
||||
|
||||
Returns:
|
||||
GeneticOptimizationParameters: The fully prepared optimization parameters.
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration values like start time are missing.
|
||||
Missing inputs cancel the run without changing providers or inventing devices.
|
||||
The same resolver serves the configuration-owned HTTP request.
|
||||
"""
|
||||
ems = get_ems()
|
||||
from akkudoktoreos.optimization.genetic.configrequest import (
|
||||
ConfigOptimizationRequest,
|
||||
)
|
||||
|
||||
# The optimization paramters
|
||||
oparams: "Optional[GeneticOptimizationParameters]" = None
|
||||
|
||||
# Check for run definitions
|
||||
if ems.start_datetime is None:
|
||||
error_msg = "Start datetime unknown."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
# Check for general predictions conditions
|
||||
if cls.config.general.latitude is None:
|
||||
default_latitude = 52.52
|
||||
logger.info(f"Latitude unknown - defaulting to {default_latitude}.")
|
||||
cls.config.general.latitude = default_latitude
|
||||
if cls.config.general.longitude is None:
|
||||
default_longitude = 13.405
|
||||
logger.info(f"Longitude unknown - defaulting to {default_longitude}.")
|
||||
cls.config.general.longitude = default_longitude
|
||||
if cls.config.prediction.hours is None:
|
||||
logger.info("Prediction hours unknown - defaulting to 48 hours.")
|
||||
cls.config.prediction.hours = 48
|
||||
if cls.config.prediction.historic_hours is None:
|
||||
logger.info("Prediction historic hours unknown - defaulting to 24 hours.")
|
||||
cls.config.prediction.historic_hours = 24
|
||||
# Check optimization definitions
|
||||
if cls.config.optimization.genetic.horizon_hours is None:
|
||||
logger.info("Optimization horizon unknown - defaulting to 24 hours.")
|
||||
cls.config.optimization.genetic.horizon_hours = 24
|
||||
if cls.config.optimization.genetic.interval_sec is None:
|
||||
logger.info("Optimization interval unknown - defaulting to 3600 seconds.")
|
||||
cls.config.optimization.genetic.interval_sec = 3600
|
||||
if cls.config.optimization.genetic.interval_sec != 3600:
|
||||
logger.info(
|
||||
f"Optimization interval '{cls.config.optimization.genetic.interval_sec}' seconds "
|
||||
"not supported - forced to 3600 seconds."
|
||||
try:
|
||||
return await ConfigOptimizationRequest().resolve()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Cannot prepare GENETIC parameters; canceling optimization with provider {}: {}",
|
||||
cls.config.feedintariff.provider,
|
||||
exc,
|
||||
)
|
||||
cls.config.optimization.genetic.interval_sec = 3600
|
||||
# Check genetic algorithm definitions
|
||||
if cls.config.optimization.genetic.individuals is None:
|
||||
logger.info("Genetic individuals unknown - defaulting to 300.")
|
||||
cls.config.optimization.genetic.individuals = 300
|
||||
if cls.config.optimization.genetic.generations is None:
|
||||
logger.info("Genetic generations unknown - defaulting to 400.")
|
||||
cls.config.optimization.genetic.generations = 400
|
||||
if "ev_soc_miss" not in cls.config.optimization.genetic.penalties:
|
||||
logger.info("Genetic penalties unknown - defaulting to ev_soc_miss = 10.")
|
||||
cls.config.optimization.genetic.penalties["ev_soc_miss"] = 10
|
||||
# Setup some basic providers if not set
|
||||
if not cls.config.weather.provider:
|
||||
cls.config.weather.provider = "OpenMeteo"
|
||||
if not cls.config.load.provider:
|
||||
cls.config.load.provider = "LoadAkkudoktor"
|
||||
|
||||
# Get start solution from last run
|
||||
start_solution = None
|
||||
last_solution = ems.genetic_solution()
|
||||
if last_solution and last_solution.start_solution:
|
||||
start_solution = last_solution.start_solution
|
||||
|
||||
# Add forecast and device data
|
||||
interval = to_duration(cls.config.optimization.genetic.interval_sec)
|
||||
power_to_energy_per_interval_factor = cls.config.optimization.genetic.interval_sec / 3600
|
||||
parameter_start_datetime = ems.start_datetime.set(hour=0, second=0, microsecond=0)
|
||||
parameter_end_datetime = parameter_start_datetime.add(hours=cls.config.prediction.hours)
|
||||
max_retries = 10
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
# Collect all the data for optimisation, but do not exceed max retries
|
||||
if attempt > max_retries:
|
||||
error_msg = f"Maximum retries {max_retries} for parameter collection exceeded. Parameter preparation attempt {attempt}."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Assure predictions are uptodate
|
||||
await cls.prediction.update_data()
|
||||
|
||||
try: # Try weather first - predition is also needed by the default PV forecast
|
||||
array = await cls.prediction.key_to_array(
|
||||
key="weather_temp_air",
|
||||
start_datetime=parameter_start_datetime,
|
||||
end_datetime=parameter_end_datetime,
|
||||
interval=interval,
|
||||
fill_method="ffill",
|
||||
)
|
||||
weather_temp_air = array.tolist()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No weather forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.weather.provider = "OpenMeteo"
|
||||
# Retry
|
||||
continue
|
||||
# Try electricity fees next - predition is also needed by the default electricity price
|
||||
# If no provider is set the fees default to 0 anyway
|
||||
if cls.config.elecfee.provider:
|
||||
try:
|
||||
array = await cls.prediction.key_to_array(
|
||||
key="elecfee_consumption_amt_kwh",
|
||||
start_datetime=parameter_start_datetime,
|
||||
end_datetime=parameter_end_datetime,
|
||||
interval=interval,
|
||||
fill_method="ffill",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No electricity fee data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.merge_settings_from_dict(
|
||||
{
|
||||
"elecfee": {
|
||||
"provider": "ElecFeeFixed",
|
||||
"elecfeefixed": {
|
||||
"consumption_amt_kwh": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00",
|
||||
"duration": "24 hours",
|
||||
"value": 0.21,
|
||||
},
|
||||
]
|
||||
},
|
||||
"consumption_percent_amt": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00",
|
||||
"duration": "24 hours",
|
||||
"value": 19.0,
|
||||
},
|
||||
]
|
||||
},
|
||||
"feedin_amt_kwh": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00",
|
||||
"duration": "24 hours",
|
||||
"value": 0.0,
|
||||
},
|
||||
]
|
||||
},
|
||||
"feedin_percent_amt": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00",
|
||||
"duration": "24 hours",
|
||||
"value": 0.0,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
# Retry
|
||||
continue
|
||||
try:
|
||||
array = await cls.prediction.key_to_array(
|
||||
key="pvforecast_ac_power",
|
||||
start_datetime=parameter_start_datetime,
|
||||
end_datetime=parameter_end_datetime,
|
||||
interval=interval,
|
||||
fill_method="linear",
|
||||
)
|
||||
pvforecast_ac_power = (array * power_to_energy_per_interval_factor).tolist()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No PV forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.merge_settings_from_dict(
|
||||
{
|
||||
"pvforecast": {
|
||||
"provider": "PVForecastPVLib",
|
||||
"max_planes": 4,
|
||||
"planes": [
|
||||
{
|
||||
"surface_tilt": 7,
|
||||
"surface_azimuth": 170,
|
||||
"userhorizon": [20, 27, 22, 20],
|
||||
"peakpower": 5.0,
|
||||
"module_model": "AXITEC_AC_410MH_144S",
|
||||
"inverter_model": "Sungrow__SH25T",
|
||||
"inverter_paco": 10000,
|
||||
"modules_per_string": 12,
|
||||
"strings_per_inverter": 1,
|
||||
},
|
||||
{
|
||||
"surface_tilt": 7,
|
||||
"surface_azimuth": 90,
|
||||
"userhorizon": [30, 30, 30, 50],
|
||||
"peakpower": 4.8,
|
||||
"module_model": "AXITEC_AC_410MH_144S",
|
||||
"inverter_model": "Sungrow__SH25T",
|
||||
"inverter_paco": 10000,
|
||||
"modules_per_string": 12,
|
||||
"strings_per_inverter": 1,
|
||||
},
|
||||
{
|
||||
"surface_tilt": 60,
|
||||
"surface_azimuth": 140,
|
||||
"userhorizon": [60, 30, 0, 30],
|
||||
"peakpower": 1.4,
|
||||
"module_model": "AXITEC_AC_410MH_144S",
|
||||
"inverter_model": "Sungrow__SH25T",
|
||||
"inverter_paco": 2000,
|
||||
"modules_per_string": 5,
|
||||
"strings_per_inverter": 1,
|
||||
},
|
||||
{
|
||||
"surface_tilt": 45,
|
||||
"surface_azimuth": 185,
|
||||
"userhorizon": [45, 25, 30, 60],
|
||||
"peakpower": 1.6,
|
||||
"module_model": "AXITEC_AC_410MH_144S",
|
||||
"inverter_model": "Sungrow__SH25T",
|
||||
"inverter_paco": 1400,
|
||||
"modules_per_string": 4,
|
||||
"strings_per_inverter": 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
# Retry
|
||||
continue
|
||||
try:
|
||||
array = await cls.prediction.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=parameter_start_datetime,
|
||||
end_datetime=parameter_end_datetime,
|
||||
interval=interval,
|
||||
fill_method="ffill",
|
||||
)
|
||||
elecprice_marketprice_wh = array.tolist()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No Electricity Marketprice forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.merge_settings_from_dict(
|
||||
{
|
||||
"elecprice": {
|
||||
"elecpricefixed": {
|
||||
"elecprice_marketprice_amt_kwh": {
|
||||
"windows": [
|
||||
{
|
||||
"duration": "1 day",
|
||||
"start_time": "00:00:00.000000",
|
||||
"value": 0.288,
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"provider": "ElecPriceFixed",
|
||||
},
|
||||
},
|
||||
)
|
||||
# Retry
|
||||
continue
|
||||
try:
|
||||
array = await cls.prediction.key_to_array(
|
||||
key="loadforecast_power_w",
|
||||
start_datetime=parameter_start_datetime,
|
||||
end_datetime=parameter_end_datetime,
|
||||
interval=interval,
|
||||
fill_method="ffill",
|
||||
)
|
||||
loadforecast_power_w = array.tolist()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No Load forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.merge_settings_from_dict(
|
||||
{
|
||||
"load": {
|
||||
"provider": "LoadAkkudoktor",
|
||||
"loadakkudoktor": {
|
||||
"loadakkudoktor_year_energy_kwh": "3000",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
# Retry
|
||||
continue
|
||||
try:
|
||||
array = await cls.prediction.key_to_array(
|
||||
key="feed_in_tariff_wh",
|
||||
start_datetime=parameter_start_datetime,
|
||||
end_datetime=parameter_end_datetime,
|
||||
interval=interval,
|
||||
fill_method="ffill",
|
||||
)
|
||||
# Prediction records already contain amount/Wh; only fixed-provider
|
||||
# configuration is expressed in amount/kWh. Preserve signs and units.
|
||||
if cls.config.feedintariff.provider == "FeedInTariffImport":
|
||||
if array.ndim != 1 or len(array) != len(pvforecast_ac_power):
|
||||
raise ValueError(
|
||||
"Imported feed-in tariff length does not match the forecast horizon"
|
||||
)
|
||||
if not np.isfinite(array).all():
|
||||
raise ValueError(
|
||||
"Imported feed-in tariff contains missing or non-finite values"
|
||||
)
|
||||
feed_in_tariff_wh = array.tolist()
|
||||
except Exception as e:
|
||||
if cls.config.feedintariff.provider == "FeedInTariffImport":
|
||||
# An external EMS supplies the resolved sale revenue. Replacing
|
||||
# missing imports with demo or purchase prices changes the economics.
|
||||
logger.error(
|
||||
"Cannot prepare GENETIC parameters: FeedInTariffImport revenue is "
|
||||
"unavailable or invalid; keeping the configured provider and "
|
||||
"canceling optimization: {}",
|
||||
e,
|
||||
)
|
||||
return None
|
||||
logger.info(
|
||||
"No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.merge_settings_from_dict(
|
||||
{
|
||||
"feedintariff": {
|
||||
"provider": "FeedInTariffFixed",
|
||||
"feedintarifffixed": {
|
||||
"feed_in_tariff_amt_kwh": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00",
|
||||
"duration": "24 hours",
|
||||
"value": 0.078,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
# Retry
|
||||
continue
|
||||
|
||||
# Add device data
|
||||
|
||||
# Batteries
|
||||
# ---------
|
||||
if cls.config.devices.max_batteries is None:
|
||||
logger.info("Number of battery devices not configured - defaulting to 1.")
|
||||
cls.config.devices.max_batteries = 1
|
||||
if cls.config.devices.max_batteries == 0:
|
||||
battery_params = None
|
||||
battery_lcos_kwh = 0
|
||||
else:
|
||||
if cls.config.devices.batteries is None:
|
||||
logger.info("No battery device data available - defaulting to demo data.")
|
||||
cls.config.devices.batteries = {
|
||||
"battery1": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 8000,
|
||||
},
|
||||
}
|
||||
try:
|
||||
# Take first battery
|
||||
battery_config = list(cls.config.devices.batteries.values())[0]
|
||||
battery_params = battery_config.to_genetic_pv_bat_param()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.devices.batteries = {
|
||||
"battery1": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 8000,
|
||||
},
|
||||
}
|
||||
# Retry
|
||||
continue
|
||||
# Levelized cost of ownership
|
||||
if battery_config.levelized_cost_of_storage_amt_kwh is None:
|
||||
logger.info(
|
||||
"No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.",
|
||||
attempt,
|
||||
)
|
||||
battery_config.levelized_cost_of_storage_amt_kwh = 0
|
||||
battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh
|
||||
# Initial SOC
|
||||
try:
|
||||
initial_soc_factor = await cls.measurement.key_to_value(
|
||||
key=battery_config.measurement_key_soc_factor,
|
||||
target_datetime=ems.start_datetime,
|
||||
time_window=to_duration(to_duration("48 hours")),
|
||||
)
|
||||
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
|
||||
logger.error(
|
||||
f"Invalid battery initial SoC factor {initial_soc_factor} - defaulting to 0.0."
|
||||
)
|
||||
initial_soc_factor = 0.0
|
||||
# genetic parameter is 0..100 as int
|
||||
initial_soc_percentage = int(initial_soc_factor * 100)
|
||||
except Exception:
|
||||
initial_soc_percentage = None
|
||||
if initial_soc_percentage is None:
|
||||
logger.info(
|
||||
f"No battery device SoC data (measurement key = '{battery_config.measurement_key_soc_factor}') available - defaulting to 0."
|
||||
)
|
||||
initial_soc_percentage = 0
|
||||
battery_params.initial_soc_percentage = initial_soc_percentage
|
||||
|
||||
# Electric Vehicles
|
||||
# -----------------
|
||||
if cls.config.devices.max_electric_vehicles is None:
|
||||
logger.info("Number of electric_vehicle devices not configured - defaulting to 1.")
|
||||
cls.config.devices.max_electric_vehicles = 1
|
||||
if cls.config.devices.max_electric_vehicles == 0:
|
||||
electric_vehicle_params = None
|
||||
else:
|
||||
if cls.config.devices.electric_vehicles is None:
|
||||
logger.info(
|
||||
"No electric vehicle device data available - defaulting to demo data."
|
||||
)
|
||||
cls.config.devices.max_electric_vehicles = 1
|
||||
cls.config.devices.electric_vehicles = {
|
||||
"ev1": {
|
||||
"device_id": "ev1",
|
||||
"capacity_wh": 50000,
|
||||
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
|
||||
"min_soc_percentage": 70,
|
||||
},
|
||||
}
|
||||
try:
|
||||
# Take first electric_vehicle
|
||||
electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0]
|
||||
electric_vehicle_params = electric_vehicle_config.to_genetic_ev_bat_param()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.devices.max_electric_vehicles = 1
|
||||
cls.config.devices.electric_vehicles = {
|
||||
"ev1": {
|
||||
"device_id": "ev1",
|
||||
"capacity_wh": 50000,
|
||||
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
|
||||
"min_soc_percentage": 70,
|
||||
},
|
||||
}
|
||||
# Retry
|
||||
continue
|
||||
# Initial SOC
|
||||
try:
|
||||
initial_soc_factor = await cls.measurement.key_to_value(
|
||||
key=electric_vehicle_config.measurement_key_soc_factor,
|
||||
target_datetime=ems.start_datetime,
|
||||
time_window=to_duration(to_duration("48 hours")),
|
||||
)
|
||||
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
|
||||
logger.error(
|
||||
f"Invalid electric vehicle initial SoC factor {initial_soc_factor} - defaulting to 0.0."
|
||||
)
|
||||
initial_soc_factor = 0.0
|
||||
# genetic parameter is 0..100 as int
|
||||
initial_soc_percentage = int(initial_soc_factor * 100)
|
||||
except Exception:
|
||||
initial_soc_percentage = None
|
||||
if initial_soc_percentage is None:
|
||||
logger.info(
|
||||
f"No electric vehicle device SoC data (measurement key = '{electric_vehicle_config.measurement_key_soc_factor}') available - defaulting to 0."
|
||||
)
|
||||
initial_soc_percentage = 0
|
||||
electric_vehicle_params.initial_soc_percentage = initial_soc_percentage
|
||||
|
||||
# Inverters
|
||||
# ---------
|
||||
if cls.config.devices.max_inverters is None:
|
||||
logger.info("Number of inverter devices not configured - defaulting to 1.")
|
||||
cls.config.devices.max_inverters = 1
|
||||
if cls.config.devices.max_inverters == 0:
|
||||
inverter_params = None
|
||||
else:
|
||||
if cls.config.devices.inverters is None:
|
||||
logger.info("No inverter device data available - defaulting to demo data.")
|
||||
cls.config.devices.inverters = {
|
||||
"inverter1": {
|
||||
"device_id": "inverter1",
|
||||
"max_power_w": 10000,
|
||||
"battery_id": battery_config.device_id,
|
||||
},
|
||||
}
|
||||
try:
|
||||
# Take first inverter
|
||||
inverter_config = list(cls.config.devices.inverters.values())[0]
|
||||
inverter_params = inverter_config.to_genetic_param()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.devices.inverters = {
|
||||
"inverter1": {
|
||||
"device_id": "inverter1",
|
||||
"max_power_w": 10000,
|
||||
"battery_id": battery_config.device_id,
|
||||
},
|
||||
}
|
||||
# Retry
|
||||
continue
|
||||
|
||||
# Home Appliances
|
||||
# ---------------
|
||||
if cls.config.devices.max_home_appliances is None:
|
||||
logger.info("Number of home appliance devices not configured - defaulting to 1.")
|
||||
cls.config.devices.max_home_appliances = 1
|
||||
if cls.config.devices.max_home_appliances == 0:
|
||||
home_appliance_params = None
|
||||
else:
|
||||
home_appliance_params = None
|
||||
if cls.config.devices.home_appliances is None:
|
||||
logger.info(
|
||||
"No home appliance device data available - defaulting to demo data."
|
||||
)
|
||||
cls.config.devices.home_appliances = {
|
||||
"dishwasher1": {
|
||||
"device_id": "dishwasher1",
|
||||
"consumption_wh": 2000,
|
||||
"duration_h": 3.0,
|
||||
"cycle_time_windows": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "08:00",
|
||||
"duration": "5 hours",
|
||||
},
|
||||
{
|
||||
"start_time": "15:00",
|
||||
"duration": "3 hours",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
# Take first appliance
|
||||
home_appliance_config = list(cls.config.devices.home_appliances.values())[0]
|
||||
home_appliance_params = home_appliance_config.to_genetic_param()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
cls.config.devices.home_appliances = {
|
||||
"dishwasher1": {
|
||||
"device_id": "dishwasher1",
|
||||
"consumption_wh": 2000,
|
||||
"duration_h": 3.0,
|
||||
"cycle_time_windows": None,
|
||||
},
|
||||
}
|
||||
# Retry
|
||||
continue
|
||||
|
||||
# We got all parameter data
|
||||
try:
|
||||
oparams = GeneticOptimizationParameters(
|
||||
ems=GeneticEnergyManagementParameters(
|
||||
pv_forecast_wh=pvforecast_ac_power,
|
||||
electricity_price_per_wh=elecprice_marketprice_wh,
|
||||
feed_in_tariff_per_wh=feed_in_tariff_wh,
|
||||
total_load=loadforecast_power_w,
|
||||
price_per_wh_battery=battery_lcos_kwh / 1000,
|
||||
),
|
||||
temperature_forecast=weather_temp_air,
|
||||
pv_battery=battery_params,
|
||||
ev=electric_vehicle_params,
|
||||
inverter=inverter_params,
|
||||
dishwasher=home_appliance_params,
|
||||
start_solution=start_solution,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"Can not prepare optimization parameters - will retry. Parameter preparation attempt {}: {}",
|
||||
attempt,
|
||||
e,
|
||||
)
|
||||
oparams = None
|
||||
# Retry
|
||||
continue
|
||||
|
||||
# Parameters prepared
|
||||
break
|
||||
|
||||
return oparams
|
||||
return None
|
||||
|
||||
@@ -3,20 +3,69 @@
|
||||
Kept in an extra module to avoid cyclic dependencies on package import.
|
||||
"""
|
||||
|
||||
from typing import Optional, Union
|
||||
from enum import StrEnum
|
||||
from typing import Any, Literal, Optional, Union
|
||||
|
||||
from pydantic import Field, computed_field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
|
||||
|
||||
def normalize_genetic_settings(value: Any) -> Any:
|
||||
"""Preserve old feature settings in the current algorithm-specific namespace.
|
||||
|
||||
Explicit nested values win. The classic flat configuration without GENETIC
|
||||
markers remains available to the existing GENETIC0 migration.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
feature_keys = (
|
||||
"tail_horizon_hours",
|
||||
"terminal_value_mode",
|
||||
"terminal_value_euro_per_kwh",
|
||||
"terminal_value_window_hours",
|
||||
"measurement_max_age_seconds",
|
||||
)
|
||||
is_genetic = value.get("algorithm") == "GENETIC" or any(key in value for key in feature_keys)
|
||||
if not is_genetic or not any(
|
||||
key in value for key in (*feature_keys, "interval", "horizon_hours")
|
||||
):
|
||||
return value
|
||||
result = dict(value)
|
||||
nested = dict(result.get("genetic") or {})
|
||||
for key in (*feature_keys, "interval", "horizon_hours"):
|
||||
if key in result:
|
||||
nested.setdefault("interval_sec" if key == "interval" else key, result.pop(key))
|
||||
result["genetic"] = nested
|
||||
return result
|
||||
|
||||
|
||||
class TerminalValueMode(StrEnum):
|
||||
"""How the energy left in the battery at the end of the horizon is valued.
|
||||
|
||||
Modes
|
||||
-----
|
||||
- AUTO:
|
||||
Solve the deterministic forecast tail and apply a conservative
|
||||
continuation proxy at its end. Tail values may decrease with SOC when
|
||||
empty capacity is valuable. With a zero tail, use the proxy directly.
|
||||
|
||||
- FIXED:
|
||||
Credit every stored kWh with the configured
|
||||
``terminal_value_euro_per_kwh`` (or ``preis_euro_pro_wh_akku`` of the
|
||||
request). The historical behaviour; a value of 0 makes the optimizer
|
||||
empty the battery towards the end of the horizon.
|
||||
"""
|
||||
|
||||
AUTO = "AUTO"
|
||||
FIXED = "FIXED"
|
||||
|
||||
|
||||
class GeneticCommonSettings(SettingsBaseModel):
|
||||
"""GENETIC Optimization Algorithm Configuration."""
|
||||
|
||||
interval_sec: int = Field(
|
||||
interval_sec: Literal[900, 3600] = Field(
|
||||
default=3600,
|
||||
ge=15 * 60,
|
||||
le=60 * 60,
|
||||
json_schema_extra={
|
||||
"description": "The optimization interval [sec]. Defaults to 3600 seconds (1 hour)",
|
||||
"examples": [60 * 60, 15 * 60],
|
||||
@@ -59,6 +108,59 @@ class GeneticCommonSettings(SettingsBaseModel):
|
||||
},
|
||||
)
|
||||
|
||||
measurement_max_age_seconds: int = Field(
|
||||
default=300,
|
||||
gt=0,
|
||||
description="Maximum age of SoC measurements for configuration-based optimization [s].",
|
||||
)
|
||||
|
||||
tail_horizon_hours: int = Field(
|
||||
default=48,
|
||||
ge=0,
|
||||
json_schema_extra={
|
||||
"description": "Forecast lookahead after the control horizon [h]. No tail commands are issued. Set 0 to disable."
|
||||
},
|
||||
)
|
||||
|
||||
terminal_value_mode: TerminalValueMode = Field(
|
||||
default=TerminalValueMode.AUTO,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"How to value the energy left in the battery at the end of the "
|
||||
"control horizon. AUTO solves the forecast tail with an AUTO "
|
||||
"continuation proxy at its end (or only the proxy if tail is zero); FIXED "
|
||||
"uses 'terminal_value_euro_per_kwh'. Defaults to AUTO."
|
||||
),
|
||||
"examples": ["AUTO", "FIXED"],
|
||||
},
|
||||
)
|
||||
|
||||
terminal_value_euro_per_kwh: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Value assigned to usable battery energy remaining at the end of the "
|
||||
"optimization horizon [EUR/kWh]. This terminal value is independent "
|
||||
"of the battery LCOS. Only used with terminal_value_mode = FIXED. "
|
||||
"Defaults to 0 EUR/kWh."
|
||||
),
|
||||
"examples": [0.0, 0.20],
|
||||
},
|
||||
)
|
||||
|
||||
terminal_value_window_hours: int = Field(
|
||||
default=24,
|
||||
ge=1,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Length of the trailing window at the effective tail end the AUTO continuation "
|
||||
"curve is derived from [h]. One day covers a full load and PV "
|
||||
"cycle. Defaults to 24 hours."
|
||||
),
|
||||
"examples": [24],
|
||||
},
|
||||
)
|
||||
|
||||
# --- Penalties (existing) -------------------------------------------------
|
||||
|
||||
penalties: dict[str, Union[float, int, str]] = Field(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Genetic algorithm optimisation solution."""
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
from typing import Any, Optional, Union, cast
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -30,8 +30,9 @@ from akkudoktoreos.optimization.genetic.geneticdevices import GeneticParametersB
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueResult
|
||||
from akkudoktoreos.optimization.optimization import OptimizationSolution
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
|
||||
from akkudoktoreos.utils.utils import NumpyEncoder
|
||||
|
||||
|
||||
@@ -239,11 +240,48 @@ class GeneticSimulationResult(GeneticParametersBaseModel):
|
||||
"losses_per_hour",
|
||||
"home_appliance_wh_per_hour",
|
||||
"electricity_price",
|
||||
"feed_in_tariff",
|
||||
mode="before",
|
||||
)
|
||||
def convert_numpy(cls, field: Any) -> Any:
|
||||
return NumpyEncoder.convert_numpy(field)[0]
|
||||
|
||||
home_appliance_energy_wh: dict[str, list[float]] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Per-device appliance energy in watt-hours per optimization slot, "
|
||||
"keyed by device_id."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
feed_in_tariff: list[float] = Field(
|
||||
validation_alias=AliasChoices("feed_in_tariff", "Feed_in_tariff"),
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Used feed-in tariff in €/Wh per hour, including predictions"
|
||||
},
|
||||
)
|
||||
|
||||
home_appliance_running: dict[str, list[bool]] = Field(
|
||||
default_factory=dict, description="Active run occupancy, including zero-power phases."
|
||||
)
|
||||
|
||||
@field_validator("home_appliance_energy_wh", mode="before")
|
||||
def convert_numpy_appliance_energy(cls, field: Any) -> Any:
|
||||
if isinstance(field, dict):
|
||||
return {
|
||||
device_id: NumpyEncoder.convert_numpy(values)[0]
|
||||
for device_id, values in field.items()
|
||||
}
|
||||
return field
|
||||
|
||||
@computed_field(json_schema_extra={"deprecated": True})
|
||||
def Feed_in_tariff(self) -> Optional[list[float]]:
|
||||
"""Deprecated: use feed_in_tariff."""
|
||||
return self.feed_in_tariff
|
||||
|
||||
|
||||
class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
"""**Note**: The first value of "load_wh_per_hour", "grid_feed_in_wh_per_hour", and "grid_consumption_wh_per_hour", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged."""
|
||||
@@ -253,55 +291,66 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
parameters: GeneticOptimizationParameters = Field(
|
||||
json_schema_extra={"description": "Optimization parameters used to generate solution."}
|
||||
)
|
||||
|
||||
ac_charge: list[float] = Field(
|
||||
json_schema_extra={
|
||||
"description": "Array with AC charging values as relative power (0.0-1.0), other values set to 0."
|
||||
}
|
||||
)
|
||||
|
||||
dc_charge: list[float] = Field(
|
||||
json_schema_extra={
|
||||
"description": "Array with DC charging values as relative power (0-1), other values set to 0."
|
||||
}
|
||||
)
|
||||
|
||||
discharge_allowed: list[int] = Field(
|
||||
json_schema_extra={
|
||||
"description": "Array with discharge values (1 for discharge, 0 otherwise)."
|
||||
}
|
||||
)
|
||||
|
||||
ev_charge_hours_float: Optional[list[float]] = Field(
|
||||
validation_alias=AliasChoices("ev_charge_hours_float", "eautocharge_hours_float"),
|
||||
json_schema_extra={
|
||||
"description": "Array with EV charging values as relative power (0.0-1.0), or `null` if no EV is optimized."
|
||||
},
|
||||
)
|
||||
|
||||
result: GeneticSimulationResult
|
||||
|
||||
ev_obj: Optional[ElectricVehicleResult] = Field(
|
||||
validation_alias=AliasChoices("ev_obj", "eauto_obj"),
|
||||
json_schema_extra={"description": "Electric vehicle state after optimization."},
|
||||
)
|
||||
|
||||
start_hour: int = Field(
|
||||
default=0,
|
||||
json_schema_extra={"description": "Start hour."},
|
||||
)
|
||||
|
||||
start_solution: Optional[list[float]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "An array of binary values (0 or 1) representing a possible starting solution for the simulation."
|
||||
},
|
||||
)
|
||||
|
||||
washingstart: Optional[int] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Can be `null` or contain an object representing the start of washing (if applicable)."
|
||||
},
|
||||
)
|
||||
|
||||
extra_data: Optional[dict[str, Union[list[int], list[float]]]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": ("Dictionary of balance: TBD, losses: TBD, constraints: TBD.")
|
||||
},
|
||||
)
|
||||
fitness_history: Optional[dict[str, Union[list[int], list[float]]]] = Field(
|
||||
|
||||
fitness_history: Optional[dict[str, Any]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
@@ -313,12 +362,12 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
fixed_seed: Optional[int] = Field(
|
||||
default=None,
|
||||
json_schema_extra={"description": "Fixed seed."},
|
||||
)
|
||||
|
||||
# Computed fields for backward compatibility (deprecated German names)
|
||||
@computed_field(json_schema_extra={"deprecated": True})
|
||||
def eautocharge_hours_float(self) -> Optional[list[float]]:
|
||||
"""Deprecated: Use ev_charge_hours_float instead."""
|
||||
@@ -333,6 +382,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
"ac_charge",
|
||||
"dc_charge",
|
||||
"discharge_allowed",
|
||||
"battery_grid_export_allowed",
|
||||
"battery_grid_export_factor",
|
||||
mode="before",
|
||||
)
|
||||
def convert_numpy(cls, field: Any) -> Any:
|
||||
@@ -347,39 +398,131 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
return ElectricVehicleResult(**field.to_dict())
|
||||
return field
|
||||
|
||||
controls_start_at_now: bool = Field(
|
||||
default=False, description="Control arrays start at the run timestamp instead of midnight."
|
||||
)
|
||||
|
||||
battery_grid_export_allowed: list[int] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
|
||||
},
|
||||
)
|
||||
|
||||
terminal_value: Optional[TerminalValueResult] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"The terminal value applied to the energy left in the battery at "
|
||||
"the end of the horizon, including the curve it was read from. "
|
||||
"None when no battery is part of the optimization."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
battery_grid_export_factor: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Array with the battery-to-grid export level per slot as factor "
|
||||
"of the rated discharge power (0.0 for no export). Empty when "
|
||||
"direct marketing is disabled; a solution without this array "
|
||||
"exports at full power wherever "
|
||||
"'battery_grid_export_allowed' is 1."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
start_solution_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Start of the slot that gene 0 of 'start_solution' controls. Send it "
|
||||
"back together with 'start_solution' so the next run can shift the "
|
||||
"warm start by the slots that have elapsed since."
|
||||
),
|
||||
"examples": [None, "2026-09-14T07:45:00+02:00"],
|
||||
},
|
||||
)
|
||||
|
||||
appliance_starts: dict[str, list[DateTime]] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Scheduled run start times per appliance device_id as absolute local datetimes."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
appliance_deadline_missed: dict[str, bool] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Per appliance device_id with a 'deadline_datetime': whether the "
|
||||
"scheduled run misses that deadline (or was not scheduled at "
|
||||
"all). Appliances without a deadline are not listed."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interval_seconds: int = Field(
|
||||
default=3600, description="Duration of one result/control slot in seconds."
|
||||
)
|
||||
|
||||
@field_validator("start_solution_datetime", mode="before")
|
||||
@classmethod
|
||||
def transform_start_solution_datetime(cls, value: Any) -> Optional[DateTime]:
|
||||
"""Accept the usual date time representations, naive input is local time."""
|
||||
return GeneticOptimizationParameters.transform_start_solution_datetime(value)
|
||||
|
||||
def _battery_device_id(self) -> str:
|
||||
"""Get battery device id."""
|
||||
parameters = getattr(self, "parameters", None)
|
||||
if parameters is not None and parameters.pv_battery is not None:
|
||||
return parameters.pv_battery.device_id
|
||||
try:
|
||||
return list(self.config.devices.batteries.values())[0].device_id
|
||||
return next(iter(self.config.devices.batteries.values())).device_id
|
||||
except Exception:
|
||||
return "battery1"
|
||||
|
||||
def _ev_device_id(self) -> str:
|
||||
"""Get electric vehicle device id."""
|
||||
parameters = getattr(self, "parameters", None)
|
||||
if parameters is not None and parameters.ev is not None:
|
||||
return parameters.ev.device_id
|
||||
try:
|
||||
return self.config.devices.electric_vehicles.values()[0].device_id
|
||||
return next(iter(self.config.devices.electric_vehicles.values())).device_id
|
||||
except Exception:
|
||||
return "ev1"
|
||||
|
||||
def _homeappliance_device_id(self) -> str:
|
||||
"""Get home appliance device id."""
|
||||
parameters = getattr(self, "parameters", None)
|
||||
if parameters is not None and parameters.resolved_home_appliances():
|
||||
return parameters.resolved_home_appliances()[0].device_id
|
||||
try:
|
||||
return self.config.devices.home_appliances.values()[0].device_id
|
||||
return next(iter(self.config.devices.home_appliances.values())).device_id
|
||||
except Exception:
|
||||
return "homeappliance1"
|
||||
|
||||
@staticmethod
|
||||
def _battery_operation_from_solution(
|
||||
self,
|
||||
ac_charge: float,
|
||||
dc_charge: float,
|
||||
discharge_allowed: bool,
|
||||
battery_grid_export_allowed: bool = False,
|
||||
battery_grid_export_factor: float = 1.0,
|
||||
) -> tuple[BatteryOperationMode, float]:
|
||||
"""Maps low-level solution to a representative operation mode and factor.
|
||||
|
||||
Args:
|
||||
ac_charge (float): Allowed AC-side charging power (relative units).
|
||||
dc_charge (float): Allowed DC-side charging power (relative units).
|
||||
discharge_allowed (bool): Whether discharging is permitted.
|
||||
discharge_allowed (bool): Whether discharging to local load is permitted.
|
||||
battery_grid_export_allowed (bool): Whether discharge into the grid is permitted.
|
||||
battery_grid_export_factor (float): Export level as factor of the rated
|
||||
discharge power ]0.0 ... 1.0]. Becomes the operation factor of
|
||||
GRID_SUPPORT_EXPORT.
|
||||
|
||||
Returns:
|
||||
tuple[BatteryOperationMode, float]: A tuple containing
|
||||
@@ -387,15 +530,30 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
- `float`: the operation factor corresponding to the active signal.
|
||||
|
||||
Notes:
|
||||
- The mapping prioritizes AC charge > DC charge > discharge.
|
||||
- Explicit grid export is separate from local-load discharge.
|
||||
- The mapping prioritizes export > AC charge > DC charge > discharge.
|
||||
- Multiple strategies can produce the same low-level signals; this function
|
||||
returns a representative mode based on a defined priority order.
|
||||
"""
|
||||
# (0,0,0) → Nothing allowed
|
||||
if ac_charge <= 0.0 and dc_charge <= 0.0 and not discharge_allowed:
|
||||
if (
|
||||
ac_charge <= 0.0
|
||||
and dc_charge <= 0.0
|
||||
and not discharge_allowed
|
||||
and not battery_grid_export_allowed
|
||||
):
|
||||
return BatteryOperationMode.IDLE, 1.0
|
||||
|
||||
# (0,0,1) → Discharge only
|
||||
if battery_grid_export_allowed:
|
||||
if ac_charge > 0.0 or dc_charge > 0.0:
|
||||
raise ValueError(
|
||||
"Illegal state: battery_grid_export_allowed cannot be combined with charging"
|
||||
)
|
||||
return BatteryOperationMode.GRID_SUPPORT_EXPORT, min(
|
||||
max(float(battery_grid_export_factor), 0.0), 1.0
|
||||
)
|
||||
|
||||
# (0,0,1) -> Discharge for local load only
|
||||
if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed:
|
||||
return BatteryOperationMode.PEAK_SHAVING, 1.0
|
||||
|
||||
@@ -436,7 +594,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
dc_charge: float,
|
||||
discharge_allowed: bool,
|
||||
soc_pct: float,
|
||||
) -> tuple[float, float, bool]:
|
||||
battery_grid_export_allowed: bool = False,
|
||||
) -> tuple[float, float, bool, bool]:
|
||||
"""Clamp raw genetic gene values by the battery's actual SOC at that hour.
|
||||
|
||||
The raw gene values represent the optimizer's *intent* and are stored
|
||||
@@ -448,16 +607,24 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
Clamping rules:
|
||||
- AC charge factor: scaled down proportionally when the battery
|
||||
headroom (max_soc − current_soc) is smaller than what the
|
||||
commanded factor would store in one hour. Set to 0 when full.
|
||||
commanded factor would store in one optimization slot. Set to 0 when full.
|
||||
- DC charge factor (PV): zeroed when battery is at or above max SOC
|
||||
(the inverter curtails automatically, but this makes intent clear).
|
||||
- Discharge: blocked when SOC is at or below min SOC.
|
||||
- Battery grid export: blocked when SOC is at or below min SOC.
|
||||
"""
|
||||
bat_dict = self.config.devices.batteries
|
||||
if bat_dict is None or len(bat_dict) <= 0:
|
||||
return ac_charge, dc_charge, discharge_allowed
|
||||
parameters = getattr(self, "parameters", None)
|
||||
bat_list = (
|
||||
[parameters.pv_battery]
|
||||
if parameters is not None and parameters.pv_battery is not None
|
||||
else list((self.config.devices.batteries or {}).values())
|
||||
if parameters is None
|
||||
else []
|
||||
)
|
||||
if not bat_list:
|
||||
return ac_charge, dc_charge, discharge_allowed, battery_grid_export_allowed
|
||||
|
||||
bat = list(bat_dict.values())[0]
|
||||
bat = bat_list[0]
|
||||
min_soc = float(bat.min_soc_percentage)
|
||||
max_soc = float(bat.max_soc_percentage)
|
||||
capacity_wh = float(bat.capacity_wh)
|
||||
@@ -470,16 +637,27 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
if headroom_wh <= 0.0:
|
||||
effective_ac = 0.0
|
||||
else:
|
||||
inv_list = self.config.devices.inverters
|
||||
inv_list = (
|
||||
[parameters.inverter]
|
||||
if parameters is not None and parameters.inverter is not None
|
||||
else list((self.config.devices.inverters or {}).values())
|
||||
if parameters is None
|
||||
else []
|
||||
)
|
||||
ac_to_dc_eff = float(inv_list[0].ac_to_dc_efficiency) if inv_list else 1.0
|
||||
max_ac_cp_w = (
|
||||
float(inv_list[0].max_ac_charge_power_w)
|
||||
if inv_list and inv_list[0].max_ac_charge_power_w is not None
|
||||
else float(bat.max_charge_power_w)
|
||||
)
|
||||
max_dc_per_h_wh = effective_ac * max_ac_cp_w * ac_to_dc_eff * ch_eff
|
||||
if max_dc_per_h_wh > headroom_wh:
|
||||
effective_ac = effective_ac * (headroom_wh / max_dc_per_h_wh)
|
||||
# Energy storable in one optimization slot, not per hour: scale the
|
||||
# power [W] by the slot duration (1.0 hourly, 0.25 at 15 min).
|
||||
slot_duration_h = float(self.interval_seconds) / 3600.0
|
||||
max_dc_per_slot_wh = (
|
||||
effective_ac * max_ac_cp_w * slot_duration_h * ac_to_dc_eff * ch_eff
|
||||
)
|
||||
if max_dc_per_slot_wh > headroom_wh:
|
||||
effective_ac = effective_ac * (headroom_wh / max_dc_per_slot_wh)
|
||||
|
||||
# --- DC charge (PV): zero when battery is full ---
|
||||
effective_dc = dc_charge
|
||||
@@ -488,36 +666,49 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
|
||||
# --- Discharge: block at min SOC ---
|
||||
effective_dis = discharge_allowed and (soc_pct > min_soc)
|
||||
effective_grid_export = battery_grid_export_allowed and (soc_pct > min_soc)
|
||||
|
||||
return effective_ac, effective_dc, effective_dis
|
||||
return effective_ac, effective_dc, effective_dis, effective_grid_export
|
||||
|
||||
async def optimization_solution(self) -> OptimizationSolution:
|
||||
"""Provide the genetic solution as a general optimization solution.
|
||||
|
||||
The battery modes are controlled by the grid control triggers:
|
||||
- ac_charge: charge from grid
|
||||
- discharge_allowed: discharge to grid
|
||||
- discharge_allowed: discharge to local load
|
||||
- battery_grid_export_allowed: discharge to grid
|
||||
|
||||
The following battery modes are supported:
|
||||
- SELF_CONSUMPTION: ac_charge == 0 and discharge_allowed == 0
|
||||
- GRID_SUPPORT_EXPORT: ac_charge == 0 and discharge_allowed == 1
|
||||
- SELF_CONSUMPTION: dc_charge > 0 and discharge_allowed == 1
|
||||
- PEAK_SHAVING: ac_charge == 0 and discharge_allowed == 1
|
||||
- GRID_SUPPORT_EXPORT: battery_grid_export_allowed == 1
|
||||
- GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1
|
||||
"""
|
||||
start_datetime = get_ems().start_datetime
|
||||
start_day_hour = start_datetime.in_timezone(self.config.general.timezone).hour
|
||||
interval_hours = 1
|
||||
power_to_energy_per_interval_factor = 1.0
|
||||
start_datetime = self.start_solution_datetime or get_ems().start_datetime
|
||||
# New controls use the run-relative control horizon; old payloads retain a midnight prefix.
|
||||
# entries indexed by slot (slot 0 == 00:00 local). Index this serializer
|
||||
# by slot too. At the default interval of 3600 s slots_per_hour == 1 and
|
||||
# this is the established hourly behaviour.
|
||||
interval_s = self.interval_seconds
|
||||
start_local = start_datetime.in_timezone(self.config.general.timezone)
|
||||
start_day_slot = (
|
||||
0
|
||||
if self.controls_start_at_now
|
||||
else int((start_local - start_local.start_of("day")).total_seconds() // interval_s)
|
||||
)
|
||||
# power [W] -> energy per slot [Wh]: multiply by the slot duration in hours.
|
||||
power_to_energy_per_interval_factor = interval_s / 3600.0
|
||||
|
||||
# --- Create index based on list length and interval ---
|
||||
# Ensure we only use the minimum of results and commands if differing
|
||||
periods = min(len(self.result.costs_per_hour), len(self.ac_charge) - start_day_hour)
|
||||
periods = min(len(self.result.costs_per_hour), len(self.ac_charge) - start_day_slot)
|
||||
time_index = pd.date_range(
|
||||
start=start_datetime,
|
||||
periods=periods,
|
||||
freq=f"{interval_hours}h",
|
||||
freq=f"{interval_s}s",
|
||||
)
|
||||
n_points = len(time_index)
|
||||
end_datetime = start_datetime.add(hours=n_points)
|
||||
end_datetime = start_datetime.add(seconds=interval_s * n_points)
|
||||
|
||||
# Fill solution into dataframe with correct column names
|
||||
# - load_energy_wh: Load of all energy consumers in wh"
|
||||
@@ -533,7 +724,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
solution = pd.DataFrame(
|
||||
{
|
||||
"date_time": time_index,
|
||||
# result starts at start_day_hour
|
||||
# result starts at start_day_slot
|
||||
"load_energy_wh": self.result.load_wh_per_hour[:n_points],
|
||||
"grid_feedin_energy_wh": self.result.grid_feed_in_wh_per_hour[:n_points],
|
||||
"grid_consumption_energy_wh": self.result.grid_consumption_wh_per_hour[:n_points],
|
||||
@@ -544,68 +735,91 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
index=time_index,
|
||||
)
|
||||
|
||||
# Add battery data
|
||||
battery_device_id = self._battery_device_id()
|
||||
solution[f"{battery_device_id}_soc_factor"] = [
|
||||
v / 100
|
||||
for v in self.result.battery_soc_per_hour[:n_points] # result starts at start_day_hour
|
||||
]
|
||||
operation: dict[str, list[float]] = {
|
||||
"genetic_ac_charge_factor": [],
|
||||
"genetic_dc_charge_factor": [],
|
||||
"genetic_discharge_allowed_factor": [],
|
||||
}
|
||||
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
|
||||
for hour_idx, rate in enumerate(self.ac_charge):
|
||||
if hour_idx < start_day_hour:
|
||||
continue
|
||||
if hour_idx >= start_day_hour + n_points:
|
||||
break
|
||||
ac_charge_hour = self.ac_charge[hour_idx]
|
||||
dc_charge_hour = self.dc_charge[hour_idx]
|
||||
discharge_allowed_hour = bool(self.discharge_allowed[hour_idx])
|
||||
if self.parameters.pv_battery is not None:
|
||||
# Add battery data
|
||||
battery_device_id = self._battery_device_id()
|
||||
solution[f"{battery_device_id}_soc_factor"] = [
|
||||
v / 100
|
||||
for v in self.result.battery_soc_per_hour[
|
||||
:n_points
|
||||
] # result starts at start_day_slot
|
||||
]
|
||||
operation: dict[str, list[float]] = {
|
||||
"genetic_ac_charge_factor": [],
|
||||
"genetic_dc_charge_factor": [],
|
||||
"genetic_discharge_allowed_factor": [],
|
||||
"genetic_battery_grid_export_allowed_factor": [],
|
||||
}
|
||||
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
|
||||
for hour_idx, rate in enumerate(self.ac_charge):
|
||||
if hour_idx < start_day_slot:
|
||||
continue
|
||||
if hour_idx >= start_day_slot + n_points:
|
||||
break
|
||||
ac_charge_hour = self.ac_charge[hour_idx]
|
||||
dc_charge_hour = self.dc_charge[hour_idx]
|
||||
discharge_allowed_hour = bool(self.discharge_allowed[hour_idx])
|
||||
battery_grid_export_allowed_hour = (
|
||||
bool(self.battery_grid_export_allowed[hour_idx])
|
||||
if hour_idx < len(self.battery_grid_export_allowed)
|
||||
else False
|
||||
)
|
||||
# Solutions written before graded export carry no factor array; they
|
||||
# exported at full power wherever the signal was set.
|
||||
battery_grid_export_factor_hour = (
|
||||
float(self.battery_grid_export_factor[hour_idx])
|
||||
if hour_idx < len(self.battery_grid_export_factor)
|
||||
else (1.0 if battery_grid_export_allowed_hour else 0.0)
|
||||
)
|
||||
|
||||
# Raw genetic gene values — optimizer intent, stored verbatim
|
||||
operation["genetic_ac_charge_factor"].append(ac_charge_hour)
|
||||
operation["genetic_dc_charge_factor"].append(dc_charge_hour)
|
||||
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour))
|
||||
# Raw genetic gene values — optimizer intent, stored verbatim
|
||||
operation["genetic_ac_charge_factor"].append(ac_charge_hour)
|
||||
operation["genetic_dc_charge_factor"].append(dc_charge_hour)
|
||||
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour))
|
||||
operation["genetic_battery_grid_export_allowed_factor"].append(
|
||||
battery_grid_export_factor_hour
|
||||
)
|
||||
|
||||
# SOC-clamped effective values — what can physically be executed at
|
||||
# this hour given the expected battery state of charge.
|
||||
result_idx = hour_idx - start_day_hour
|
||||
soc_h_pct = (
|
||||
self.result.battery_soc_per_hour[result_idx]
|
||||
if result_idx < len(self.result.battery_soc_per_hour)
|
||||
else 0.0
|
||||
)
|
||||
eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors(
|
||||
ac_charge_hour, dc_charge_hour, discharge_allowed_hour, soc_h_pct
|
||||
)
|
||||
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
||||
eff_ac, eff_dc, eff_dis
|
||||
)
|
||||
for mode in BatteryOperationMode:
|
||||
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode"
|
||||
factor_key = f"{battery_device_id}_{mode.lower()}_op_factor"
|
||||
if mode_key not in operation.keys():
|
||||
operation[mode_key] = []
|
||||
operation[factor_key] = []
|
||||
if mode == operation_mode:
|
||||
operation[mode_key].append(1.0)
|
||||
operation[factor_key].append(operation_mode_factor)
|
||||
else:
|
||||
operation[mode_key].append(0.0)
|
||||
operation[factor_key].append(0.0)
|
||||
for key in operation.keys():
|
||||
if len(operation[key]) != n_points:
|
||||
error_msg = f"instruction {key} has invalid length {len(operation[key])} - expected {n_points}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
solution[key] = operation[key]
|
||||
# SOC-clamped effective values — what can physically be executed at
|
||||
# this hour given the expected battery state of charge.
|
||||
result_idx = hour_idx - start_day_slot
|
||||
soc_h_pct = (
|
||||
self.result.battery_soc_per_hour[result_idx]
|
||||
if result_idx < len(self.result.battery_soc_per_hour)
|
||||
else 0.0
|
||||
)
|
||||
eff_ac, eff_dc, eff_dis, eff_grid_export = self._soc_clamped_operation_factors(
|
||||
ac_charge_hour,
|
||||
dc_charge_hour,
|
||||
discharge_allowed_hour,
|
||||
soc_h_pct,
|
||||
battery_grid_export_allowed_hour,
|
||||
)
|
||||
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
||||
eff_ac, eff_dc, eff_dis, eff_grid_export, battery_grid_export_factor_hour
|
||||
)
|
||||
for mode in BatteryOperationMode:
|
||||
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode"
|
||||
factor_key = f"{battery_device_id}_{mode.lower()}_op_factor"
|
||||
if mode_key not in operation.keys():
|
||||
operation[mode_key] = []
|
||||
operation[factor_key] = []
|
||||
if mode == operation_mode:
|
||||
operation[mode_key].append(1.0)
|
||||
operation[factor_key].append(operation_mode_factor)
|
||||
else:
|
||||
operation[mode_key].append(0.0)
|
||||
operation[factor_key].append(0.0)
|
||||
for key in operation.keys():
|
||||
if len(operation[key]) != n_points:
|
||||
error_msg = f"instruction {key} has invalid length {len(operation[key])} - expected {n_points}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
solution[key] = operation[key]
|
||||
|
||||
# Add EV battery solution
|
||||
# ev_charge_hours_float start at hour 0 of start day
|
||||
# result.ev_soc_per_hour start at start_datetime.hour
|
||||
# eautocharge_hours_float start at hour 0 of start day
|
||||
# result.EAuto_SoC_pro_Stunde start at start_datetime.hour
|
||||
if self.ev_obj:
|
||||
ev_device_id = self._ev_device_id()
|
||||
if self.ev_charge_hours_float is None:
|
||||
@@ -633,9 +847,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
"genetic_ev_charge_factor": [],
|
||||
}
|
||||
for hour_idx, rate in enumerate(self.ev_charge_hours_float):
|
||||
if hour_idx < start_day_hour:
|
||||
if hour_idx < start_day_slot:
|
||||
continue
|
||||
if hour_idx >= start_day_hour + n_points:
|
||||
if hour_idx >= start_day_slot + n_points:
|
||||
break
|
||||
operation["genetic_ev_charge_factor"].append(rate)
|
||||
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
||||
@@ -660,32 +874,33 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
raise ValueError(error_msg)
|
||||
solution[key] = operation[key]
|
||||
|
||||
# Add home appliance data
|
||||
if self.config.devices.max_home_appliances and self.config.devices.max_home_appliances > 0:
|
||||
# Use config and not self.washingstart as washingstart may be None (no start)
|
||||
# even if configured to be started.
|
||||
homeappliance_device_id = self._homeappliance_device_id()
|
||||
# result starts at start_day_hour
|
||||
solution[f"{homeappliance_device_id}_energy_wh"] = (
|
||||
self.result.home_appliance_wh_per_hour[:n_points]
|
||||
)
|
||||
# Add home appliance data, one block of columns per device. Per-device
|
||||
# energy arrays start at start_day_slot, like the other result arrays.
|
||||
for device_id, energy_wh in self.result.home_appliance_energy_wh.items():
|
||||
solution[f"{device_id}_energy_wh"] = energy_wh[:n_points]
|
||||
operation = {
|
||||
f"{homeappliance_device_id}_run_op_mode": [],
|
||||
f"{homeappliance_device_id}_run_op_factor": [],
|
||||
f"{homeappliance_device_id}_off_op_mode": [],
|
||||
f"{homeappliance_device_id}_off_op_factor": [],
|
||||
f"{device_id}_run_op_mode": [],
|
||||
f"{device_id}_run_op_factor": [],
|
||||
f"{device_id}_off_op_mode": [],
|
||||
f"{device_id}_off_op_factor": [],
|
||||
}
|
||||
for hour_idx, energy in enumerate(solution[f"{homeappliance_device_id}_energy_wh"]):
|
||||
if energy > 0.0:
|
||||
operation[f"{homeappliance_device_id}_run_op_mode"].append(1.0)
|
||||
operation[f"{homeappliance_device_id}_run_op_factor"].append(1.0)
|
||||
operation[f"{homeappliance_device_id}_off_op_mode"].append(0.0)
|
||||
operation[f"{homeappliance_device_id}_off_op_factor"].append(0.0)
|
||||
running_slots = self.result.home_appliance_running.get(device_id, [])
|
||||
for hour_idx, energy in enumerate(solution[f"{device_id}_energy_wh"]):
|
||||
running = (
|
||||
running_slots[hour_idx]
|
||||
if hour_idx < len(running_slots)
|
||||
else bool(energy and energy > 0.0)
|
||||
)
|
||||
if running:
|
||||
operation[f"{device_id}_run_op_mode"].append(1.0)
|
||||
operation[f"{device_id}_run_op_factor"].append(1.0)
|
||||
operation[f"{device_id}_off_op_mode"].append(0.0)
|
||||
operation[f"{device_id}_off_op_factor"].append(0.0)
|
||||
else:
|
||||
operation[f"{homeappliance_device_id}_run_op_mode"].append(0.0)
|
||||
operation[f"{homeappliance_device_id}_run_op_factor"].append(0.0)
|
||||
operation[f"{homeappliance_device_id}_off_op_mode"].append(1.0)
|
||||
operation[f"{homeappliance_device_id}_off_op_factor"].append(1.0)
|
||||
operation[f"{device_id}_run_op_mode"].append(0.0)
|
||||
operation[f"{device_id}_run_op_factor"].append(0.0)
|
||||
operation[f"{device_id}_off_op_mode"].append(1.0)
|
||||
operation[f"{device_id}_off_op_factor"].append(1.0)
|
||||
for key in operation.keys():
|
||||
if len(operation[key]) != n_points:
|
||||
error_msg = f"instruction {key} has invalid length {len(operation[key])} - expected {n_points}"
|
||||
@@ -706,18 +921,33 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
},
|
||||
index=time_index,
|
||||
)
|
||||
pred = get_prediction()
|
||||
if self.controls_start_at_now:
|
||||
snapshot = self.parameters.ems
|
||||
prediction["pvforecast_ac_energy_wh"] = snapshot.pv_forecast_wh[:n_points]
|
||||
prediction["loadforecast_energy_wh"] = snapshot.total_load[:n_points]
|
||||
prediction["elec_price_amt_kwh"] = (
|
||||
np.asarray(snapshot.electricity_price_per_wh[:n_points]) * 1000
|
||||
)
|
||||
tariffs = snapshot.feed_in_tariff_per_wh
|
||||
prediction["feed_in_tariff_amt_kwh"] = (
|
||||
np.asarray(tariffs[:n_points]) * 1000
|
||||
if isinstance(tariffs, list)
|
||||
else tariffs * 1000
|
||||
)
|
||||
if self.controls_start_at_now and self.parameters.temperature_forecast is not None:
|
||||
prediction["weather_air_temp_celcius"] = self.parameters.temperature_forecast[:n_points]
|
||||
pred = None if self.controls_start_at_now else get_prediction()
|
||||
|
||||
prediction_specs: list[tuple[str, FillMethod, str, float]] = [
|
||||
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [
|
||||
(
|
||||
"pvforecast_ac_power",
|
||||
"linear",
|
||||
"ffill",
|
||||
"pvforecast_ac_energy_wh",
|
||||
power_to_energy_per_interval_factor,
|
||||
),
|
||||
(
|
||||
"pvforecast_dc_power",
|
||||
"linear",
|
||||
"ffill",
|
||||
"pvforecast_dc_energy_wh",
|
||||
power_to_energy_per_interval_factor,
|
||||
),
|
||||
@@ -741,32 +971,32 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
),
|
||||
(
|
||||
"loadforecast_power_w",
|
||||
"linear",
|
||||
"ffill",
|
||||
"loadforecast_energy_wh",
|
||||
power_to_energy_per_interval_factor,
|
||||
),
|
||||
(
|
||||
"loadakkudoktor_std_power_w",
|
||||
"linear",
|
||||
"ffill",
|
||||
"loadakkudoktor_std_energy_wh",
|
||||
power_to_energy_per_interval_factor,
|
||||
),
|
||||
(
|
||||
"loadakkudoktor_mean_power_w",
|
||||
"linear",
|
||||
"ffill",
|
||||
"loadakkudoktor_mean_energy_wh",
|
||||
power_to_energy_per_interval_factor,
|
||||
),
|
||||
]
|
||||
|
||||
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in prediction_specs:
|
||||
if pred_key in pred.record_keys:
|
||||
]:
|
||||
if pred_solution_key in prediction.columns:
|
||||
continue
|
||||
if pred is not None and pred_key in pred.record_keys:
|
||||
array = await pred.key_to_array(
|
||||
key=pred_key,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
interval=to_duration(f"{interval_hours} hours"),
|
||||
fill_method=pred_fill_method,
|
||||
interval=to_duration(f"{interval_s} seconds"),
|
||||
fill_method=cast(FillMethod, pred_fill_method),
|
||||
)
|
||||
# 'key_to_array()' creates None values array if no data records are available.
|
||||
if array is not None and array.size > 0 and not np.any(pd.isna(array)):
|
||||
@@ -777,7 +1007,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
generated_at=to_datetime(),
|
||||
comment="Optimization solution derived from GeneticSolution.",
|
||||
valid_from=start_datetime,
|
||||
valid_until=start_datetime.add(hours=self.config.optimization.genetic.horizon_hours),
|
||||
valid_until=end_datetime,
|
||||
total_losses_energy_wh=self.result.total_losses,
|
||||
total_revenues_amt=self.result.total_revenue,
|
||||
total_costs_amt=self.result.total_costs,
|
||||
@@ -792,8 +1022,16 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
|
||||
def energy_management_plan(self) -> EnergyManagementPlan:
|
||||
"""Provide the genetic solution as an energy management plan."""
|
||||
start_datetime = get_ems().start_datetime
|
||||
start_day_hour = start_datetime.in_timezone(self.config.general.timezone).hour
|
||||
start_datetime = self.start_solution_datetime or get_ems().start_datetime
|
||||
# Index by slot, not hour (mirrors optimization_solution). At the default
|
||||
# interval of 3600 s this reduces to the start hour-of-day.
|
||||
interval_s = self.interval_seconds
|
||||
start_local = start_datetime.in_timezone(self.config.general.timezone)
|
||||
start_day_slot = (
|
||||
0
|
||||
if self.controls_start_at_now
|
||||
else int((start_local - start_local.start_of("day")).total_seconds() // interval_s)
|
||||
)
|
||||
plan = EnergyManagementPlan(
|
||||
id=f"plan-genetic@{to_datetime(as_string=True)}",
|
||||
generated_at=to_datetime(),
|
||||
@@ -801,55 +1039,70 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
comment="Energy management plan derived from GeneticSolution.",
|
||||
)
|
||||
|
||||
# Add battery instructions (fill rate based control)
|
||||
last_operation_mode: Optional[str] = None
|
||||
last_operation_mode_factor: Optional[float] = None
|
||||
resource_id = self._battery_device_id()
|
||||
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
|
||||
logger.debug("BAT: {} - {}", resource_id, self.ac_charge[start_day_hour:])
|
||||
for hour_idx, rate in enumerate(self.ac_charge):
|
||||
if hour_idx < start_day_hour:
|
||||
continue
|
||||
# Derive SOC-clamped effective factors so that FRBCInstruction
|
||||
# operation_mode_factor reflects what can physically be executed,
|
||||
# while the raw genetic gene values are preserved in the solution
|
||||
# dataframe (genetic_*_factor columns).
|
||||
result_idx = hour_idx - start_day_hour
|
||||
soc_h_pct = (
|
||||
self.result.battery_soc_per_hour[result_idx]
|
||||
if result_idx < len(self.result.battery_soc_per_hour)
|
||||
else 0.0
|
||||
)
|
||||
eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors(
|
||||
self.ac_charge[hour_idx],
|
||||
self.dc_charge[hour_idx],
|
||||
bool(self.discharge_allowed[hour_idx]),
|
||||
soc_h_pct,
|
||||
)
|
||||
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
||||
eff_ac, eff_dc, eff_dis
|
||||
)
|
||||
if (
|
||||
operation_mode == last_operation_mode
|
||||
and operation_mode_factor == last_operation_mode_factor
|
||||
):
|
||||
# Skip, we already added the instruction
|
||||
continue
|
||||
last_operation_mode = operation_mode
|
||||
last_operation_mode_factor = operation_mode_factor
|
||||
execution_time = start_datetime.add(hours=hour_idx - start_day_hour)
|
||||
plan.add_instruction(
|
||||
FRBCInstruction(
|
||||
resource_id=resource_id,
|
||||
execution_time=execution_time,
|
||||
actuator_id=resource_id,
|
||||
operation_mode_id=operation_mode,
|
||||
operation_mode_factor=operation_mode_factor,
|
||||
if self.parameters.pv_battery is not None:
|
||||
# Add battery instructions (fill rate based control)
|
||||
last_operation_mode: Optional[str] = None
|
||||
last_operation_mode_factor: Optional[float] = None
|
||||
resource_id = self._battery_device_id()
|
||||
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
|
||||
logger.debug("BAT: {} - {}", resource_id, self.ac_charge[start_day_slot:])
|
||||
for hour_idx, rate in enumerate(self.ac_charge):
|
||||
if hour_idx < start_day_slot:
|
||||
continue
|
||||
# Derive SOC-clamped effective factors so that FRBCInstruction
|
||||
# operation_mode_factor reflects what can physically be executed,
|
||||
# while the raw genetic gene values are preserved in the solution
|
||||
# dataframe (genetic_*_factor columns).
|
||||
result_idx = hour_idx - start_day_slot
|
||||
soc_h_pct = (
|
||||
self.result.battery_soc_per_hour[result_idx]
|
||||
if result_idx < len(self.result.battery_soc_per_hour)
|
||||
else 0.0
|
||||
)
|
||||
battery_grid_export_allowed_hour = (
|
||||
bool(self.battery_grid_export_allowed[hour_idx])
|
||||
if hour_idx < len(self.battery_grid_export_allowed)
|
||||
else False
|
||||
)
|
||||
eff_ac, eff_dc, eff_dis, eff_grid_export = self._soc_clamped_operation_factors(
|
||||
self.ac_charge[hour_idx],
|
||||
self.dc_charge[hour_idx],
|
||||
bool(self.discharge_allowed[hour_idx]),
|
||||
soc_h_pct,
|
||||
battery_grid_export_allowed_hour,
|
||||
)
|
||||
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
||||
eff_ac,
|
||||
eff_dc,
|
||||
eff_dis,
|
||||
eff_grid_export,
|
||||
self.battery_grid_export_factor[hour_idx]
|
||||
if hour_idx < len(self.battery_grid_export_factor)
|
||||
else 1.0,
|
||||
)
|
||||
if (
|
||||
operation_mode == last_operation_mode
|
||||
and operation_mode_factor == last_operation_mode_factor
|
||||
):
|
||||
# Skip, we already added the instruction
|
||||
continue
|
||||
last_operation_mode = operation_mode
|
||||
last_operation_mode_factor = operation_mode_factor
|
||||
execution_time = start_datetime.add(
|
||||
seconds=interval_s * (hour_idx - start_day_slot)
|
||||
)
|
||||
plan.add_instruction(
|
||||
FRBCInstruction(
|
||||
resource_id=resource_id,
|
||||
execution_time=execution_time,
|
||||
actuator_id=resource_id,
|
||||
operation_mode_id=operation_mode,
|
||||
operation_mode_factor=operation_mode_factor,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Add EV battery instructions (fill rate based control)
|
||||
# ev_charge_hours_float start at hour 0 of start day
|
||||
# eautocharge_hours_float start at hour 0 of start day
|
||||
if self.ev_obj:
|
||||
resource_id = self._ev_device_id()
|
||||
if self.ev_charge_hours_float is None:
|
||||
@@ -868,10 +1121,10 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
last_operation_mode = None
|
||||
last_operation_mode_factor = None
|
||||
logger.debug(
|
||||
"EV: {} - {}", resource_id, self.ev_charge_hours_float[start_day_hour:]
|
||||
"EV: {} - {}", resource_id, self.ev_charge_hours_float[start_day_slot:]
|
||||
)
|
||||
for hour_idx, rate in enumerate(self.ev_charge_hours_float):
|
||||
if hour_idx < start_day_hour:
|
||||
if hour_idx < start_day_slot:
|
||||
continue
|
||||
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
||||
rate, 0.0, False
|
||||
@@ -884,7 +1137,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
continue
|
||||
last_operation_mode = operation_mode
|
||||
last_operation_mode_factor = operation_mode_factor
|
||||
execution_time = start_datetime.add(hours=hour_idx - start_day_hour)
|
||||
execution_time = start_datetime.add(
|
||||
seconds=interval_s * (hour_idx - start_day_slot)
|
||||
)
|
||||
plan.add_instruction(
|
||||
FRBCInstruction(
|
||||
resource_id=resource_id,
|
||||
@@ -895,34 +1150,36 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
)
|
||||
)
|
||||
|
||||
# Add home appliance instructions (demand driven based control)
|
||||
if self.config.devices.max_home_appliances and self.config.devices.max_home_appliances > 0:
|
||||
# Use config and not self.washingstart as washingstart may be None (no start)
|
||||
# even if configured to be started.
|
||||
resource_id = self._homeappliance_device_id()
|
||||
last_energy: Optional[float] = None
|
||||
for hours, energy in enumerate(self.result.home_appliance_wh_per_hour):
|
||||
# Add home appliance instructions (demand driven based control), one
|
||||
# stream of instructions per device. A new instruction is only emitted on
|
||||
# a transition between OFF (energy == 0) and RUN (energy > 0); a mere
|
||||
# power change within a running profile does not add an instruction.
|
||||
for resource_id, energy_wh in self.result.home_appliance_energy_wh.items():
|
||||
last_state: Optional[bool] = None
|
||||
for hours, energy in enumerate(energy_wh):
|
||||
# hours starts at start_datetime with 0
|
||||
if energy is None:
|
||||
raise ValueError(
|
||||
f"Unexpected value {energy} in {self.result.home_appliance_wh_per_hour}"
|
||||
f"Unexpected value {energy} in home_appliance_energy_wh[{resource_id}]"
|
||||
)
|
||||
if last_energy is None or energy != last_energy:
|
||||
if energy > 0.0:
|
||||
operation_mode = ApplianceOperationMode.RUN # type: ignore[assignment]
|
||||
else:
|
||||
operation_mode = ApplianceOperationMode.OFF # type: ignore[assignment]
|
||||
operation_mode_factor = 1.0
|
||||
execution_time = start_datetime.add(hours=hours)
|
||||
running_slots = self.result.home_appliance_running.get(resource_id, [])
|
||||
running = running_slots[hours] if hours < len(running_slots) else energy > 0.0
|
||||
if last_state is None or running != last_state:
|
||||
appliance_mode = (
|
||||
ApplianceOperationMode.RUN if running else ApplianceOperationMode.OFF
|
||||
)
|
||||
execution_time = start_datetime.add(seconds=interval_s * hours)
|
||||
plan.add_instruction(
|
||||
DDBCInstruction(
|
||||
resource_id=resource_id,
|
||||
execution_time=execution_time,
|
||||
actuator_id=resource_id,
|
||||
operation_mode_id=operation_mode,
|
||||
operation_mode_factor=operation_mode_factor,
|
||||
operation_mode_id=appliance_mode,
|
||||
operation_mode_factor=1.0,
|
||||
)
|
||||
)
|
||||
last_energy = energy
|
||||
last_state = running
|
||||
|
||||
if not plan.instructions:
|
||||
plan.valid_from = start_datetime
|
||||
return plan
|
||||
|
||||
@@ -27,7 +27,11 @@ class GeneticVisualizationReport(ConfigMixin):
|
||||
def __init__(
|
||||
self,
|
||||
version: str = "0.0.1",
|
||||
interval_seconds: int = 3600,
|
||||
) -> None:
|
||||
if interval_seconds <= 0:
|
||||
raise ValueError("Report interval must be positive")
|
||||
self.interval_seconds = interval_seconds
|
||||
# Initialize the report with empty groups
|
||||
self.groups: list[list[Callable[[], None]]] = [] # Store groups of charts
|
||||
self.current_group: list[
|
||||
@@ -129,12 +133,16 @@ class GeneticVisualizationReport(ConfigMixin):
|
||||
line_styles: Optional[list[str]] = None,
|
||||
x2label: Optional[Union[str, None]] = "Hours Since Start",
|
||||
) -> None:
|
||||
"""Create a line chart and add it to the current group."""
|
||||
"""Plot interval values at their start timestamps without energy rescaling."""
|
||||
if not y_list or not len(y_list[0]):
|
||||
return
|
||||
if any(len(values) != len(y_list[0]) for values in y_list):
|
||||
raise ValueError("Date chart series must cover the same intervals")
|
||||
|
||||
def chart() -> None:
|
||||
timestamps = [
|
||||
start_date.add(hours=i) for i in range(len(y_list[0]))
|
||||
] # 840 timestamps at 1-hour intervals
|
||||
start_date.add(seconds=i * self.interval_seconds) for i in range(len(y_list[0]))
|
||||
]
|
||||
|
||||
for idx, y_data in enumerate(y_list):
|
||||
label = labels[idx] if labels else None # Chart label
|
||||
@@ -150,18 +158,14 @@ class GeneticVisualizationReport(ConfigMixin):
|
||||
|
||||
# Format the time axis
|
||||
plt.gca().xaxis.set_major_formatter(
|
||||
mdates.DateFormatter("%Y-%m-%d", tz=self.config.general.timezone)
|
||||
mdates.DateFormatter("%Y-%m-%d", tz=start_date.tzinfo)
|
||||
) # Show date and time
|
||||
plt.gca().xaxis.set_major_locator(
|
||||
mdates.DayLocator(interval=1, tz=self.config.general.timezone)
|
||||
mdates.DayLocator(interval=1, tz=start_date.tzinfo)
|
||||
) # Major ticks every day
|
||||
plt.gca().xaxis.set_minor_locator(
|
||||
mdates.HourLocator(interval=2, tz=self.config.general.timezone)
|
||||
)
|
||||
plt.gca().xaxis.set_minor_locator(mdates.HourLocator(interval=2, tz=start_date.tzinfo))
|
||||
# Minor ticks every 6 hours
|
||||
plt.gca().xaxis.set_minor_formatter(
|
||||
mdates.DateFormatter("%H", tz=self.config.general.timezone)
|
||||
)
|
||||
plt.gca().xaxis.set_minor_formatter(mdates.DateFormatter("%H", tz=start_date.tzinfo))
|
||||
# plt.gcf().autofmt_xdate(rotation=45, which="major")
|
||||
# Auto-format the x-axis for readability
|
||||
|
||||
@@ -203,10 +207,12 @@ class GeneticVisualizationReport(ConfigMixin):
|
||||
# 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(
|
||||
mdates.date2num(timestamps[:: len(timestamps) // 12])
|
||||
mdates.date2num(timestamps[:: max(1, 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]])
|
||||
ax2.set_xticklabels(
|
||||
[f"{h:g}" for h in hours_since_start[:: max(1, len(timestamps) // 12)]]
|
||||
)
|
||||
if x2label:
|
||||
ax2.set_xlabel(x2label)
|
||||
|
||||
@@ -455,14 +461,18 @@ def genetic_prepare_visualize(
|
||||
) -> bytes:
|
||||
global debug_visualize
|
||||
|
||||
report = GeneticVisualizationReport()
|
||||
next_full_hour_date = get_ems().start_datetime
|
||||
start_hour = solution.start_hour
|
||||
report = GeneticVisualizationReport(interval_seconds=solution.interval_seconds)
|
||||
# New solutions own their timeline. The fallback supports historical hourly solutions.
|
||||
start_datetime = solution.start_solution_datetime or get_ems().start_datetime
|
||||
start_offset = 0 if solution.controls_start_at_now else solution.start_hour
|
||||
control_slots = len(solution.result.load_wh_per_hour)
|
||||
end_offset = start_offset + control_slots
|
||||
# Forecasts can include a diagnostic tail; report executable intervals here.
|
||||
# Group 1:
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date,
|
||||
start_datetime,
|
||||
[
|
||||
solution.parameters.ems.total_load[start_hour:],
|
||||
solution.parameters.ems.total_load[start_offset:end_offset],
|
||||
],
|
||||
title="Load Profile",
|
||||
# xlabel="Hours", # not enough space
|
||||
@@ -470,9 +480,9 @@ def genetic_prepare_visualize(
|
||||
labels=["Total Load (Wh)"],
|
||||
)
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date,
|
||||
start_datetime,
|
||||
[
|
||||
solution.parameters.ems.pv_forecast_wh[start_hour:],
|
||||
solution.parameters.ems.pv_forecast_wh[start_offset:end_offset],
|
||||
],
|
||||
title="PV Forecast",
|
||||
# xlabel="Hours", # not enough space
|
||||
@@ -480,14 +490,11 @@ def genetic_prepare_visualize(
|
||||
)
|
||||
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date,
|
||||
start_datetime,
|
||||
[
|
||||
np.full(
|
||||
len(solution.parameters.ems.total_load) - start_hour,
|
||||
solution.parameters.ems.feed_in_tariff_per_wh[start_hour:]
|
||||
if isinstance(solution.parameters.ems.feed_in_tariff_per_wh, list)
|
||||
else solution.parameters.ems.feed_in_tariff_per_wh,
|
||||
)
|
||||
np.asarray(solution.parameters.ems.feed_in_tariff_per_wh[start_offset:end_offset])
|
||||
if isinstance(solution.parameters.ems.feed_in_tariff_per_wh, list)
|
||||
else np.full(control_slots, solution.parameters.ems.feed_in_tariff_per_wh)
|
||||
],
|
||||
title="Remuneration",
|
||||
# xlabel="Hours", # not enough space
|
||||
@@ -496,9 +503,9 @@ def genetic_prepare_visualize(
|
||||
)
|
||||
if solution.parameters.temperature_forecast:
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date,
|
||||
start_datetime,
|
||||
[
|
||||
solution.parameters.temperature_forecast[start_hour:],
|
||||
solution.parameters.temperature_forecast[start_offset:end_offset],
|
||||
],
|
||||
title="Temperature Forecast",
|
||||
# xlabel="Hours", # not enough space
|
||||
@@ -509,7 +516,7 @@ def genetic_prepare_visualize(
|
||||
|
||||
# Group 2:
|
||||
report.create_line_chart_date(
|
||||
start_date=next_full_hour_date, # start_date
|
||||
start_date=start_datetime, # start_date
|
||||
y_list=[
|
||||
solution.result.load_wh_per_hour,
|
||||
solution.result.home_appliance_wh_per_hour,
|
||||
@@ -517,7 +524,7 @@ def genetic_prepare_visualize(
|
||||
solution.result.grid_consumption_wh_per_hour,
|
||||
solution.result.losses_per_hour,
|
||||
],
|
||||
title="Energy Flow per Hour",
|
||||
title="Energy Flow per Interval",
|
||||
# xlabel="Date", # not enough space
|
||||
ylabel="Energy (Wh)",
|
||||
labels=[
|
||||
@@ -534,9 +541,9 @@ def genetic_prepare_visualize(
|
||||
|
||||
# Group 3:
|
||||
report.create_line_chart_date(
|
||||
start_date=next_full_hour_date,
|
||||
start_date=start_datetime,
|
||||
y_list=[solution.result.battery_soc_per_hour, solution.result.ev_soc_per_hour],
|
||||
title="Battery SOC",
|
||||
title="Battery SOC at Interval Start",
|
||||
# xlabel="Date", # not enough space
|
||||
ylabel="%",
|
||||
labels=[
|
||||
@@ -546,54 +553,60 @@ def genetic_prepare_visualize(
|
||||
markers=["o", "x"],
|
||||
)
|
||||
report.create_line_chart_date(
|
||||
start_date=next_full_hour_date, # start_date
|
||||
y_list=[solution.parameters.ems.electricity_price_per_wh[start_hour:]],
|
||||
start_date=start_datetime, # start_date
|
||||
y_list=[solution.parameters.ems.electricity_price_per_wh[start_offset:end_offset]],
|
||||
# title="Electricity Price", # not enough space
|
||||
# xlabel="Date", # not enough space
|
||||
ylabel="Electricity Price (amount/Wh)",
|
||||
x2label=None, # not enough space
|
||||
)
|
||||
|
||||
labels = list(
|
||||
item
|
||||
for sublist in zip(
|
||||
list(str(i) for i in range(0, 23, 2)), list(str(" ") for i in range(0, 23, 2))
|
||||
)
|
||||
for item in sublist
|
||||
)
|
||||
labels = labels[start_hour:] + labels
|
||||
|
||||
report.create_bar_chart(
|
||||
labels=labels,
|
||||
values_list=[
|
||||
solution.ac_charge[start_hour:],
|
||||
solution.dc_charge[start_hour:],
|
||||
solution.discharge_allowed[start_hour:],
|
||||
],
|
||||
title="AC/DC Charging and Discharge Overview",
|
||||
ylabel="Relative Power (0-1) / Discharge (0 or 1)",
|
||||
label_names=["AC Charging (relative)", "DC Charging (relative)", "Discharge Allowed"],
|
||||
colors=["blue", "green", "red"],
|
||||
bottom=3,
|
||||
xlabels=labels,
|
||||
controls: list[Union[np.ndarray, list[Optional[float]], list[float]]] = [
|
||||
solution.ac_charge[start_offset:end_offset],
|
||||
solution.dc_charge[start_offset:end_offset],
|
||||
np.asarray(solution.discharge_allowed[start_offset:end_offset]),
|
||||
]
|
||||
control_labels = ["AC Charging (relative)", "DC Charging (relative)", "Discharge Allowed"]
|
||||
if solution.battery_grid_export_allowed:
|
||||
controls.append(np.asarray(solution.battery_grid_export_allowed[start_offset:end_offset]))
|
||||
control_labels.append("Battery Grid Export Allowed")
|
||||
if solution.battery_grid_export_factor:
|
||||
controls.append(solution.battery_grid_export_factor[start_offset:end_offset])
|
||||
control_labels.append("Battery Grid Export (relative)")
|
||||
report.create_line_chart_date(
|
||||
start_datetime,
|
||||
controls,
|
||||
title="Executable Battery Controls",
|
||||
ylabel="Relative Power / Allowed (0-1)",
|
||||
labels=control_labels,
|
||||
)
|
||||
report.finalize_group()
|
||||
|
||||
# Group 4:
|
||||
|
||||
report.create_line_chart_date(
|
||||
next_full_hour_date, # start_date
|
||||
start_datetime, # start_date
|
||||
[
|
||||
solution.result.costs_per_hour,
|
||||
solution.result.revenue_per_hour,
|
||||
],
|
||||
title="Financial Balance per Hour",
|
||||
title="Financial Balance per Interval",
|
||||
# xlabel="Date", # not enough space
|
||||
ylabel="Amount",
|
||||
labels=["Costs", "Revenue"],
|
||||
)
|
||||
|
||||
extra_data = solution.extra_data
|
||||
if extra_data:
|
||||
# Historical native runs used German diagnostic keys. Prefer current public keys.
|
||||
extra_data = {
|
||||
key: extra_data.get(key, extra_data.get(alias, []))
|
||||
for key, alias in (
|
||||
("losses", "verluste"),
|
||||
("balance", "bilanz"),
|
||||
("constraints", "nebenbedingung"),
|
||||
)
|
||||
}
|
||||
if extra_data:
|
||||
report.create_scatter_plot(
|
||||
x=np.array(extra_data["losses"]),
|
||||
@@ -683,6 +696,8 @@ def genetic_prepare_visualize(
|
||||
)
|
||||
report.finalize_group()
|
||||
|
||||
_add_solution_diagnostics(report, solution, start_datetime)
|
||||
|
||||
# Generate the PDF report
|
||||
pdf = report.generate_pdf()
|
||||
|
||||
@@ -690,6 +705,119 @@ def genetic_prepare_visualize(
|
||||
return pdf
|
||||
|
||||
|
||||
def _add_solution_diagnostics(
|
||||
report: GeneticVisualizationReport, solution: GeneticSolution, start: DateTime
|
||||
) -> None:
|
||||
"""Render the retained run diagnostics; tail actions are never executable controls."""
|
||||
report.add_text_page(
|
||||
f"Run start: {start.isoformat()}. Interval: {report.interval_seconds} seconds. "
|
||||
"Energy values are Wh per interval; prices are amount/Wh. "
|
||||
"Timeline positions mark interval starts. Battery SOC shows the beginning of each interval.",
|
||||
title="GENETIC Run",
|
||||
)
|
||||
report.finalize_group()
|
||||
device_ids = sorted(
|
||||
solution.result.home_appliance_energy_wh.keys()
|
||||
| solution.appliance_starts.keys()
|
||||
| solution.appliance_deadline_missed.keys()
|
||||
)
|
||||
for device_id in device_ids:
|
||||
energy = solution.result.home_appliance_energy_wh.get(device_id, [])
|
||||
report.create_line_chart_date(
|
||||
start,
|
||||
[energy],
|
||||
title=f"Flexible Consumer: {device_id}",
|
||||
ylabel="Energy (Wh per interval)",
|
||||
)
|
||||
if energy:
|
||||
report.finalize_group()
|
||||
starts = ", ".join(
|
||||
value.isoformat() for value in solution.appliance_starts.get(device_id, [])
|
||||
)
|
||||
report.add_text_page(
|
||||
f"Scheduled starts: {starts or 'none'}. "
|
||||
f"Deadline missed: {solution.appliance_deadline_missed.get(device_id, False)}.",
|
||||
title=f"Flexible Consumer Schedule: {device_id}",
|
||||
)
|
||||
report.finalize_group()
|
||||
terminal = solution.terminal_value
|
||||
if terminal is None:
|
||||
return
|
||||
report.add_text_page(
|
||||
f"Mode: {terminal.mode}. Reason: {terminal.reason or 'none'}. "
|
||||
f"Control horizon: {terminal.control_horizon_hours:g} h. "
|
||||
f"Requested tail: {terminal.requested_tail_hours:g} h; "
|
||||
f"effective tail: {terminal.effective_tail_hours:g} h; "
|
||||
f"tail end: {terminal.tail_end_hour:g} h after run start. "
|
||||
f"Battery usable AC energy at control end: {terminal.battery_energy_wh:g} Wh. "
|
||||
f"Credited terminal value: {terminal.credited_euro:g} EUR. "
|
||||
f"Tail operating value: {terminal.tail_operating_euro:g} EUR. "
|
||||
f"Continuation value: {terminal.continuation_value_euro:g} EUR "
|
||||
f"({terminal.continuation_mode}). "
|
||||
"Tail results are diagnostic lookahead only, not executable controls. "
|
||||
"The terminal credit is a modeled residual value, not realized revenue.",
|
||||
title="Terminal Value and Tail Diagnostics",
|
||||
)
|
||||
report.finalize_group()
|
||||
if terminal.tail_diagnostics is not None:
|
||||
report.add_json_page(
|
||||
terminal.tail_diagnostics.model_dump(), title="Tail Forecast Summary", fontsize=10
|
||||
)
|
||||
report.finalize_group()
|
||||
curve = terminal.curve
|
||||
if curve is not None and curve.energy_wh:
|
||||
|
||||
def value_chart() -> None:
|
||||
plt.plot(curve.energy_wh, curve.value_euro, label="Terminal credit")
|
||||
plt.scatter(
|
||||
[terminal.battery_energy_wh], [terminal.credited_euro], label="Selected state"
|
||||
)
|
||||
plt.xlabel("Usable AC Battery Energy (Wh)")
|
||||
plt.ylabel("Terminal Value (EUR)")
|
||||
plt.title("Residual Battery Value Curve")
|
||||
plt.legend()
|
||||
plt.grid(True)
|
||||
|
||||
report.add_chart_to_group(value_chart, "Residual Battery Value Curve")
|
||||
report.finalize_group()
|
||||
if terminal.tail_plan:
|
||||
# Use explicit hour offsets, which also preserve a clipped tail's true start.
|
||||
plan = terminal.tail_plan
|
||||
|
||||
def tail_chart() -> None:
|
||||
dates = [start.add(seconds=slot.hour_from_start * 3600) for slot in plan]
|
||||
for field, label in (
|
||||
("grid_import_wh", "Grid import"),
|
||||
("grid_export_wh", "Grid export"),
|
||||
("battery_charge_wh", "Battery charge"),
|
||||
("battery_discharge_wh", "Battery discharge"),
|
||||
):
|
||||
plt.plot(
|
||||
mdates.date2num(dates),
|
||||
[getattr(slot, field) for slot in plan],
|
||||
label=label,
|
||||
marker="o" if len(plan) == 1 else None,
|
||||
)
|
||||
if len(plan) == 1:
|
||||
# A lone sample otherwise disappears and datetime autoscaling spans years.
|
||||
half_interval = report.interval_seconds / 2
|
||||
plt.xlim(
|
||||
mdates.date2num(dates[0].subtract(seconds=half_interval)),
|
||||
mdates.date2num(dates[0].add(seconds=half_interval)),
|
||||
)
|
||||
plt.gca().xaxis.set_major_formatter(
|
||||
mdates.DateFormatter("%m-%d %H:%M", tz=start.tzinfo)
|
||||
)
|
||||
plt.xlabel("Interval Start (diagnostic only)")
|
||||
plt.ylabel("Energy (Wh per interval)")
|
||||
plt.title("Tail Lookahead: Not Executable Controls")
|
||||
plt.legend()
|
||||
plt.grid(True)
|
||||
|
||||
report.add_chart_to_group(tail_chart, "Tail Lookahead")
|
||||
report.finalize_group()
|
||||
|
||||
|
||||
def genetic_generate_example_report(filename: str = "example_report.pdf") -> None:
|
||||
"""Generate example visualization report."""
|
||||
global debug_visualize
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Chronological, run-local battery lookahead using the production device physics.
|
||||
|
||||
The Bellman recursion interpolates continuation values on a stored-energy grid.
|
||||
Tail actions never enter the executable control arrays. The selected path may
|
||||
be returned separately as diagnostics so users can inspect the lookahead.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from akkudoktoreos.devices.genetic.battery import Battery
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import (
|
||||
TailPlanSlot,
|
||||
TerminalValueCurve,
|
||||
)
|
||||
|
||||
TailAction = tuple[int, int, float, float]
|
||||
|
||||
|
||||
def _action_name(action: TailAction) -> str:
|
||||
dc, discharge, ac_rate, export_rate = action
|
||||
if export_rate > 0:
|
||||
return "BATTERY_EXPORT"
|
||||
if ac_rate > 0:
|
||||
return "GRID_CHARGE"
|
||||
if dc and discharge:
|
||||
return "SELF_CONSUMPTION"
|
||||
if dc:
|
||||
return "PV_CHARGE_ONLY"
|
||||
if discharge:
|
||||
return "DISCHARGE_ONLY"
|
||||
return "HOLD"
|
||||
|
||||
|
||||
def _simulate_action(
|
||||
*,
|
||||
bat: Battery,
|
||||
inv: Inverter,
|
||||
energy_wh: float,
|
||||
action: TailAction,
|
||||
price: float,
|
||||
load: float,
|
||||
pv: float,
|
||||
tariff: float,
|
||||
direct_marketing: bool,
|
||||
) -> dict[str, float]:
|
||||
"""Apply one tail action from one stored-energy state."""
|
||||
dc, discharge, ac_rate, export = action
|
||||
bat.soc_wh = float(energy_wh)
|
||||
bat._charged_raw_wh_per_slot.fill(0)
|
||||
bat._discharged_raw_wh_per_slot.fill(0)
|
||||
ac_enabled = inv.ac_to_dc_efficiency > 0 and (
|
||||
inv.max_ac_charge_power_w is None or inv.max_ac_charge_power_w > 0
|
||||
)
|
||||
bat.charge_array[0] = ac_rate if ac_rate > 0 and ac_enabled else dc
|
||||
bat.discharge_array[0] = discharge if export == 0 or tariff > 0 else 0
|
||||
sold, bought, losses, _ = inv.process_energy(
|
||||
pv,
|
||||
load,
|
||||
0,
|
||||
allow_battery_grid_export=direct_marketing and export > 0 and tariff > 0,
|
||||
battery_grid_export_factor=export,
|
||||
)
|
||||
ac_grid_charge_wh = 0.0
|
||||
if ac_rate > 0 and inv.ac_to_dc_efficiency > 0:
|
||||
rate = ac_rate
|
||||
if inv.max_ac_charge_power_w is not None and bat.max_charge_power_w > 0:
|
||||
rate = min(
|
||||
rate,
|
||||
inv.max_ac_charge_power_w * inv.ac_to_dc_efficiency / bat.max_charge_power_w,
|
||||
)
|
||||
bat.charge_array[0] = rate
|
||||
if rate > 0:
|
||||
stored, loss = bat.charge_energy(None, 0, charge_factor=rate)
|
||||
ac_grid_charge_wh = (stored + loss) / inv.ac_to_dc_efficiency
|
||||
bought += ac_grid_charge_wh
|
||||
losses += loss + max(ac_grid_charge_wh - stored - loss, 0.0)
|
||||
if direct_marketing and tariff < 0:
|
||||
sold = 0.0
|
||||
discharged_wh = bat.discharged_energy_wh(0)
|
||||
reward = (
|
||||
sold * tariff - bought * price - (discharged_wh * bat.levelized_cost_of_storage_kwh / 1000)
|
||||
)
|
||||
return {
|
||||
"next_state_wh": bat.soc_wh,
|
||||
"reward_euro": reward,
|
||||
"grid_export_wh": sold,
|
||||
"grid_import_wh": bought,
|
||||
"battery_charge_wh": (bat._charged_raw_wh_per_slot[0] * bat.charging_efficiency),
|
||||
"battery_discharge_wh": discharged_wh,
|
||||
"losses_wh": losses,
|
||||
"ac_grid_charge_wh": ac_grid_charge_wh,
|
||||
}
|
||||
|
||||
|
||||
class TailValueCurve(TerminalValueCurve):
|
||||
"""Value of usable AC battery energy, including the value of empty capacity.
|
||||
|
||||
Neither values nor marginal values are constrained to be monotone. The empty
|
||||
state can earn money by charging at negative prices and selling later.
|
||||
"""
|
||||
|
||||
_trace_context: dict = PrivateAttr(default_factory=dict)
|
||||
|
||||
def value(self, energy_wh: float) -> float:
|
||||
return (
|
||||
float(np.interp(energy_wh, self.energy_wh, self.value_euro)) if self.energy_wh else 0.0
|
||||
)
|
||||
|
||||
def component_values(self, energy_wh: float) -> tuple[float, float]:
|
||||
"""Return tail operating cash flow and continuation credit separately."""
|
||||
if not self.energy_wh:
|
||||
return 0.0, 0.0
|
||||
operating = float(np.interp(energy_wh, self.energy_wh, self.operating_value_euro))
|
||||
continuation = float(np.interp(energy_wh, self.energy_wh, self.continuation_value_euro))
|
||||
return operating, continuation
|
||||
|
||||
def diagnostic_plan(self, energy_wh: float, control_horizon_hours: float) -> list[TailPlanSlot]:
|
||||
"""Replay the optimal tail path for one control-end battery state."""
|
||||
context = self._trace_context
|
||||
if not context:
|
||||
return []
|
||||
bat = Battery(
|
||||
context["battery_parameters"],
|
||||
prediction_hours=1,
|
||||
slot_duration_h=context["slot_duration_h"],
|
||||
)
|
||||
inv = Inverter(
|
||||
context["inverter_parameters"],
|
||||
battery=bat,
|
||||
slot_duration_h=context["slot_duration_h"],
|
||||
)
|
||||
conversion = bat.discharging_efficiency * inv.dc_to_ac_efficiency
|
||||
state_wh = bat.min_soc_wh + (energy_wh / conversion if conversion > 0 else 0.0)
|
||||
state_wh = float(np.clip(state_wh, bat.min_soc_wh, bat.max_soc_wh))
|
||||
plan: list[TailPlanSlot] = []
|
||||
arrays = zip(
|
||||
context["prices"],
|
||||
context["load"],
|
||||
context["pv"],
|
||||
context["tariffs"],
|
||||
)
|
||||
for slot, (price, load, pv, tariff) in enumerate(arrays):
|
||||
candidates: list[tuple[float, TailAction, dict[str, float], float]] = []
|
||||
for action in context["actions"]:
|
||||
result = _simulate_action(
|
||||
bat=bat,
|
||||
inv=inv,
|
||||
energy_wh=state_wh,
|
||||
action=action,
|
||||
price=price,
|
||||
load=load,
|
||||
pv=pv,
|
||||
tariff=tariff,
|
||||
direct_marketing=context["direct_marketing"],
|
||||
)
|
||||
remaining = float(
|
||||
np.interp(
|
||||
result["next_state_wh"],
|
||||
context["states"],
|
||||
context["future_values"][slot + 1],
|
||||
)
|
||||
)
|
||||
candidates.append((result["reward_euro"] + remaining, action, result, remaining))
|
||||
candidates.sort(key=lambda candidate: candidate[0], reverse=True)
|
||||
chosen_value, chosen_action, chosen_result, remaining = candidates[0]
|
||||
alternative = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in candidates[1:]
|
||||
if _action_name(candidate[1]) != _action_name(chosen_action)
|
||||
),
|
||||
candidates[1] if len(candidates) > 1 else candidates[0],
|
||||
)
|
||||
dc, discharge, ac_rate, export_rate = chosen_action
|
||||
start_soc = state_wh / bat.capacity_wh * 100
|
||||
state_wh = chosen_result["next_state_wh"]
|
||||
plan.append(
|
||||
TailPlanSlot(
|
||||
slot=slot,
|
||||
hour_from_start=control_horizon_hours + slot * context["slot_duration_h"],
|
||||
action=_action_name(chosen_action),
|
||||
alternative_action=_action_name(alternative[1]),
|
||||
decision_margin_euro=max(chosen_value - alternative[0], 0.0),
|
||||
soc_start_percentage=start_soc,
|
||||
soc_end_percentage=state_wh / bat.capacity_wh * 100,
|
||||
pv_wh=pv,
|
||||
load_wh=load,
|
||||
grid_import_wh=chosen_result["grid_import_wh"],
|
||||
grid_export_wh=chosen_result["grid_export_wh"],
|
||||
battery_charge_wh=chosen_result["battery_charge_wh"],
|
||||
battery_discharge_wh=chosen_result["battery_discharge_wh"],
|
||||
import_price_euro_per_kwh=price * 1000,
|
||||
feed_in_tariff_euro_per_kwh=tariff * 1000,
|
||||
slot_value_euro=chosen_result["reward_euro"],
|
||||
remaining_value_euro=remaining,
|
||||
ac_charge_factor=ac_rate,
|
||||
dc_charge_allowed=dc,
|
||||
discharge_allowed=discharge,
|
||||
battery_grid_export_factor=export_rate,
|
||||
)
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
def build_tail_value_curve(
|
||||
*,
|
||||
battery: Battery,
|
||||
inverter: Inverter,
|
||||
prices_euro_per_wh: np.ndarray,
|
||||
load_wh: np.ndarray,
|
||||
pv_wh: np.ndarray,
|
||||
feed_in_euro_per_wh: np.ndarray,
|
||||
continuation: TerminalValueCurve,
|
||||
charge_rates: list[float],
|
||||
export_rates: list[float],
|
||||
direct_marketing: bool,
|
||||
grid_points: int = 101,
|
||||
) -> TailValueCurve:
|
||||
"""Solve the finite tail once, backwards in time, without mutating run devices.
|
||||
|
||||
LCOS uses delivered DC energy, exactly as in GeneticSimulation. State grid
|
||||
endpoints include battery minimum and maximum SOC. Continuous next states are
|
||||
interpolated rather than rounded (which would invent or destroy energy).
|
||||
"""
|
||||
arrays = [
|
||||
np.asarray(a, dtype=float)
|
||||
for a in (prices_euro_per_wh, load_wh, pv_wh, feed_in_euro_per_wh)
|
||||
]
|
||||
if len({len(a) for a in arrays}) != 1 or any(not np.isfinite(a).all() for a in arrays):
|
||||
raise ValueError("Tail forecasts must have equal lengths and contain only finite values")
|
||||
bat = Battery(battery.parameters, prediction_hours=1, slot_duration_h=battery.slot_duration_h)
|
||||
bat.charge_array = np.zeros(1, dtype=float)
|
||||
inv = Inverter(inverter.parameters, battery=bat, slot_duration_h=battery.slot_duration_h)
|
||||
states = np.linspace(bat.min_soc_wh, bat.max_soc_wh, grid_points)
|
||||
usable = (states - bat.min_soc_wh) * bat.discharging_efficiency * inv.dc_to_ac_efficiency
|
||||
continuation_values = np.array([continuation.value(e) for e in usable])
|
||||
operating_values = np.zeros(len(states))
|
||||
values = continuation_values.copy()
|
||||
# (DC charge, local discharge, AC rate, export rate). Preserve production
|
||||
# modes; direct marketing permits disabling DC charge to make headroom.
|
||||
actions: list[TailAction] = [(1, 0, 0.0, 0.0), (1, 1, 0.0, 0.0)]
|
||||
actions += [(1, 0, rate, 0.0) for rate in charge_rates if rate > 0]
|
||||
if direct_marketing:
|
||||
actions += [(0, 0, 0.0, 0.0), (0, 1, 0.0, 0.0)]
|
||||
actions += [(0, 0, rate, 0.0) for rate in charge_rates if rate > 0]
|
||||
actions += [(dc, 1, 0.0, rate) for dc in (0, 1) for rate in export_rates if rate > 0]
|
||||
future_values: list[np.ndarray] = [np.empty(0)] * (len(arrays[0]) + 1)
|
||||
future_values[-1] = continuation_values.copy()
|
||||
for slot in reversed(range(len(arrays[0]))):
|
||||
price, load, pv, tariff = (array[slot] for array in arrays)
|
||||
best = np.full(len(states), -np.inf)
|
||||
best_operating = np.zeros(len(states))
|
||||
best_continuation = np.zeros(len(states))
|
||||
for action in actions:
|
||||
next_states = np.empty(len(states))
|
||||
rewards = np.empty(len(states))
|
||||
for i, energy in enumerate(states):
|
||||
action_result = _simulate_action(
|
||||
bat=bat,
|
||||
inv=inv,
|
||||
energy_wh=energy,
|
||||
action=action,
|
||||
price=price,
|
||||
load=load,
|
||||
pv=pv,
|
||||
tariff=tariff,
|
||||
direct_marketing=direct_marketing,
|
||||
)
|
||||
rewards[i] = action_result["reward_euro"]
|
||||
next_states[i] = action_result["next_state_wh"]
|
||||
candidate_operating = rewards + np.interp(next_states, states, operating_values)
|
||||
candidate_continuation = np.interp(next_states, states, continuation_values)
|
||||
candidate = candidate_operating + candidate_continuation
|
||||
better = candidate > best
|
||||
best[better] = candidate[better]
|
||||
best_operating[better] = candidate_operating[better]
|
||||
best_continuation[better] = candidate_continuation[better]
|
||||
values = best
|
||||
operating_values = best_operating
|
||||
continuation_values = best_continuation
|
||||
future_values[slot] = best.copy()
|
||||
result = TailValueCurve(
|
||||
energy_wh=usable.tolist(),
|
||||
value_euro=values.tolist(),
|
||||
operating_value_euro=operating_values.tolist(),
|
||||
continuation_value_euro=continuation_values.tolist(),
|
||||
marginal_euro_per_kwh=(np.diff(values) / np.maximum(np.diff(usable), 1e-9) * 1000).tolist(),
|
||||
window_slots=len(arrays[0]),
|
||||
)
|
||||
result._trace_context = {
|
||||
"battery_parameters": battery.parameters,
|
||||
"inverter_parameters": inverter.parameters,
|
||||
"slot_duration_h": battery.slot_duration_h,
|
||||
"states": states,
|
||||
"future_values": future_values,
|
||||
"actions": actions,
|
||||
"prices": arrays[0],
|
||||
"load": arrays[1],
|
||||
"pv": arrays[2],
|
||||
"tariffs": arrays[3],
|
||||
"direct_marketing": direct_marketing,
|
||||
}
|
||||
return result
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Terminal value of the energy left in the battery at the end of the horizon.
|
||||
|
||||
The optimizer stops at the horizon, but the energy still stored in the battery
|
||||
keeps its worth: it replaces grid imports that would otherwise be paid for
|
||||
afterwards. Crediting that worth with a single price per kWh - the historical
|
||||
``preis_euro_pro_wh_akku`` - cannot describe it, because the value of stored
|
||||
energy is **not linear in the amount stored**:
|
||||
|
||||
- The first kWh replaces the most expensive hour after the horizon.
|
||||
- The next one replaces the second most expensive hour, and so on.
|
||||
- Once every hour that PV cannot cover is served, further energy replaces
|
||||
nothing; it is worth an export at best, and nothing at worst.
|
||||
|
||||
The resulting value function is monotone and concave. A scalar has to pick one
|
||||
slope: high enough for the first kWh means hoarding a full battery, low enough
|
||||
for the last kWh means running it empty by midnight. This module builds the
|
||||
curve instead.
|
||||
|
||||
There is no forecast beyond the horizon, so the trailing window of the horizon
|
||||
itself stands in for the day that follows: same season, same household rhythm,
|
||||
same tariff structure. That approximation is the reason the curve is a planning
|
||||
aid, not a prediction - which is also why the marginal values are deliberately
|
||||
conservative wherever a choice exists.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
|
||||
|
||||
class TerminalValueCurve(PydanticBaseModel):
|
||||
"""Piecewise linear, concave value of battery energy left at the horizon.
|
||||
|
||||
``energy_wh`` and ``value_euro`` are the breakpoints of the cumulative
|
||||
value, ``marginal_euro_per_kwh`` the slope of each segment. Both arrays
|
||||
start at the origin; the curve is flat beyond its last breakpoint.
|
||||
"""
|
||||
|
||||
energy_wh: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Breakpoints of usable AC energy left in the battery [Wh]."
|
||||
},
|
||||
)
|
||||
value_euro: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={"description": "Cumulative credit at each breakpoint [EUR]."},
|
||||
)
|
||||
operating_value_euro: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Tail operating component at each breakpoint [EUR]; empty for a proxy curve."
|
||||
},
|
||||
)
|
||||
continuation_value_euro: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Continuation component at each breakpoint [EUR]; empty for a proxy curve."
|
||||
},
|
||||
)
|
||||
marginal_euro_per_kwh: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Marginal value of the segment that starts at each breakpoint "
|
||||
"[EUR/kWh]. May be negative or non-monotone in TAIL mode."
|
||||
)
|
||||
},
|
||||
)
|
||||
residual_energy_wh: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Energy up to which the curve is backed by residual load - the "
|
||||
"knee. Everything beyond it is only worth an export."
|
||||
)
|
||||
},
|
||||
)
|
||||
window_slots: int = Field(
|
||||
default=0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Number of trailing horizon slots the curve was derived from. "
|
||||
"Fewer slots than a full day mean a shorter proxy period."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def value(self, energy_wh: float) -> float:
|
||||
"""Return the credit for ``energy_wh`` of usable AC energy [EUR].
|
||||
|
||||
Args:
|
||||
energy_wh: Usable AC energy left in the battery.
|
||||
|
||||
Returns:
|
||||
Interpolated value of the curve; 0.0 for an empty curve.
|
||||
"""
|
||||
if not self.energy_wh or energy_wh <= 0.0:
|
||||
return 0.0
|
||||
return float(np.interp(energy_wh, self.energy_wh, self.value_euro))
|
||||
|
||||
|
||||
class TailDiagnostics(PydanticBaseModel):
|
||||
"""Forecast summary used by the deterministic tail optimization."""
|
||||
|
||||
slots: int = 0
|
||||
slot_hours: float = 0.0
|
||||
soc_grid_points: int = 0
|
||||
min_import_price_euro_per_kwh: float = 0.0
|
||||
max_import_price_euro_per_kwh: float = 0.0
|
||||
min_feed_in_tariff_euro_per_kwh: float = 0.0
|
||||
max_feed_in_tariff_euro_per_kwh: float = 0.0
|
||||
negative_import_price_slots: int = 0
|
||||
positive_battery_export_slots: int = 0
|
||||
|
||||
|
||||
class TailPlanSlot(PydanticBaseModel):
|
||||
"""One diagnostic slot of the optimal tail path.
|
||||
|
||||
These values explain the lookahead used for fitness. They are diagnostics
|
||||
only and are never copied into the executable control arrays.
|
||||
"""
|
||||
|
||||
slot: int
|
||||
hour_from_start: float
|
||||
action: str
|
||||
alternative_action: str = ""
|
||||
decision_margin_euro: float = 0.0
|
||||
soc_start_percentage: float
|
||||
soc_end_percentage: float
|
||||
pv_wh: float
|
||||
load_wh: float
|
||||
grid_import_wh: float
|
||||
grid_export_wh: float
|
||||
battery_charge_wh: float
|
||||
battery_discharge_wh: float
|
||||
import_price_euro_per_kwh: float
|
||||
feed_in_tariff_euro_per_kwh: float
|
||||
slot_value_euro: float
|
||||
remaining_value_euro: float
|
||||
ac_charge_factor: float
|
||||
dc_charge_allowed: int
|
||||
discharge_allowed: int
|
||||
battery_grid_export_factor: float
|
||||
|
||||
|
||||
class TerminalValueResult(PydanticBaseModel):
|
||||
"""What the optimizer credited for the energy left in the battery."""
|
||||
|
||||
control_horizon_hours: float = 0
|
||||
requested_tail_hours: float = 0
|
||||
effective_tail_hours: float = 0
|
||||
tail_end_hour: float = 0
|
||||
continuation_mode: str = "FIXED"
|
||||
|
||||
mode: str = Field(
|
||||
json_schema_extra={
|
||||
"description": "Terminal value mode the run used: TAIL, AUTO or FIXED.",
|
||||
"examples": ["TAIL", "AUTO", "FIXED"],
|
||||
}
|
||||
)
|
||||
battery_energy_wh: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": "Usable AC energy left in the battery at the end of the horizon [Wh]."
|
||||
},
|
||||
)
|
||||
credited_euro: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={"description": "Credit applied to the total balance [EUR]."},
|
||||
)
|
||||
tail_operating_euro: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Optimal net cash flow within the effective tail for the selected "
|
||||
"control-end battery state [EUR]."
|
||||
)
|
||||
},
|
||||
)
|
||||
continuation_value_euro: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Continuation credit remaining at the end of the optimal tail path [EUR]."
|
||||
)
|
||||
},
|
||||
)
|
||||
curve: Optional[TerminalValueCurve] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Combined tail value curve (tail operation plus continuation) read by fitness; "
|
||||
"None in FIXED mode."
|
||||
)
|
||||
},
|
||||
)
|
||||
continuation_curve: Optional[TerminalValueCurve] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Conservative AUTO proxy constructed at the effective tail end."
|
||||
},
|
||||
)
|
||||
tail_diagnostics: Optional[TailDiagnostics] = None
|
||||
tail_plan: list[TailPlanSlot] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Diagnostic optimal battery path inside the tail. It explains the "
|
||||
"lookahead but is never an executable control plan."
|
||||
)
|
||||
},
|
||||
)
|
||||
reason: str = Field(
|
||||
default="",
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Why this mode applied. Empty in AUTO mode; in FIXED mode it "
|
||||
"says whether FIXED was configured or whether AUTO fell back "
|
||||
"because no curve could be derived."
|
||||
),
|
||||
"examples": ["", "terminal_value_mode is FIXED"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_terminal_value_curve(
|
||||
*,
|
||||
prices_euro_per_wh: np.ndarray,
|
||||
load_wh: np.ndarray,
|
||||
pv_wh: np.ndarray,
|
||||
feed_in_euro_per_wh: np.ndarray,
|
||||
max_energy_wh: float,
|
||||
lcos_euro_per_kwh: float = 0.0,
|
||||
dc_to_ac_efficiency: float = 1.0,
|
||||
grid_export_allowed: bool = False,
|
||||
) -> TerminalValueCurve:
|
||||
"""Build the terminal value curve from the trailing horizon window.
|
||||
|
||||
Every slot of the window contributes its residual load - the part of the
|
||||
load that PV does not cover - at its import price. Sorting those slots by
|
||||
price and accumulating them yields the marginal value of the first, second,
|
||||
... kWh in the battery. Energy beyond the residual load can only be
|
||||
exported, and only when direct marketing allows it.
|
||||
|
||||
Args:
|
||||
prices_euro_per_wh: Import prices of the window [EUR/Wh].
|
||||
load_wh: Load per slot of the window [Wh].
|
||||
pv_wh: PV generation per slot of the window [Wh].
|
||||
feed_in_euro_per_wh: Feed-in tariff of the window [EUR/Wh].
|
||||
max_energy_wh: Usable AC energy of a full battery [Wh]; the curve ends here.
|
||||
lcos_euro_per_kwh: Levelized cost of storage, already charged per
|
||||
delivered DC energy in the simulation and therefore subtracted here
|
||||
so stored energy is not credited twice.
|
||||
dc_to_ac_efficiency: Inverter efficiency, used to convert the LCOS from
|
||||
delivered DC energy to the AC energy of the curve.
|
||||
grid_export_allowed: Whether the battery may feed the grid (direct
|
||||
marketing). Without it, energy beyond the residual load gets no
|
||||
credit: it can neither be exported nor is its use covered by the
|
||||
proxy window.
|
||||
|
||||
Returns:
|
||||
The curve; empty when the window carries no usable information.
|
||||
"""
|
||||
window = min(len(prices_euro_per_wh), len(load_wh), len(pv_wh))
|
||||
if window <= 0 or max_energy_wh <= 0.0:
|
||||
return TerminalValueCurve()
|
||||
|
||||
residual = np.maximum(load_wh[:window] - pv_wh[:window], 0.0)
|
||||
prices = np.asarray(prices_euro_per_wh[:window], dtype=float)
|
||||
|
||||
# LCOS is charged on delivered DC energy; the curve is in AC energy.
|
||||
lcos_per_wh_ac = (lcos_euro_per_kwh / 1000.0) / max(dc_to_ac_efficiency, 1e-9)
|
||||
|
||||
order = np.argsort(-prices)
|
||||
energy_points: list[float] = [0.0]
|
||||
value_points: list[float] = [0.0]
|
||||
marginals: list[float] = []
|
||||
|
||||
cumulative_energy = 0.0
|
||||
cumulative_value = 0.0
|
||||
for index in order:
|
||||
slot_energy = float(residual[index])
|
||||
if slot_energy <= 0.0:
|
||||
continue
|
||||
# Negative or very cheap hours are not worth storing energy for.
|
||||
marginal = max(float(prices[index]) - lcos_per_wh_ac, 0.0)
|
||||
if marginal <= 0.0:
|
||||
continue
|
||||
slot_energy = min(slot_energy, max_energy_wh - cumulative_energy)
|
||||
if slot_energy <= 0.0:
|
||||
break
|
||||
cumulative_energy += slot_energy
|
||||
cumulative_value += slot_energy * marginal
|
||||
energy_points.append(cumulative_energy)
|
||||
value_points.append(cumulative_value)
|
||||
marginals.append(marginal * 1000.0)
|
||||
|
||||
# Everything beyond the residual load can only be sold. A median feed-in
|
||||
# tariff rather than the best one: exporting all of it in the single best
|
||||
# slot is not something the horizon can promise.
|
||||
residual_energy_wh = cumulative_energy
|
||||
if grid_export_allowed and cumulative_energy < max_energy_wh:
|
||||
positive_feed_in = [
|
||||
float(value) for value in feed_in_euro_per_wh[:window] if float(value) > 0.0
|
||||
]
|
||||
export_marginal = max(
|
||||
(float(np.median(positive_feed_in)) if positive_feed_in else 0.0) - lcos_per_wh_ac,
|
||||
0.0,
|
||||
)
|
||||
if export_marginal > 0.0:
|
||||
remaining = max_energy_wh - cumulative_energy
|
||||
cumulative_energy += remaining
|
||||
cumulative_value += remaining * export_marginal
|
||||
energy_points.append(cumulative_energy)
|
||||
value_points.append(cumulative_value)
|
||||
marginals.append(export_marginal * 1000.0)
|
||||
|
||||
if len(energy_points) <= 1:
|
||||
logger.debug("Terminal value curve is empty - no priced residual load in the window.")
|
||||
return TerminalValueCurve(window_slots=window)
|
||||
|
||||
# The segment slopes are decreasing by construction (prices were sorted),
|
||||
# so the curve is concave; the export tail is the flattest segment.
|
||||
return TerminalValueCurve(
|
||||
energy_wh=energy_points,
|
||||
value_euro=value_points,
|
||||
marginal_euro_per_kwh=marginals,
|
||||
residual_energy_wh=residual_energy_wh,
|
||||
window_slots=window,
|
||||
)
|
||||
|
||||
|
||||
def trailing_window(
|
||||
values: Optional[np.ndarray],
|
||||
end_slot: int,
|
||||
window_slots: int,
|
||||
) -> np.ndarray:
|
||||
"""Return the ``window_slots`` values in front of ``end_slot``.
|
||||
|
||||
Args:
|
||||
values: Full slot array, or None.
|
||||
end_slot: Exclusive end of the window (end of the optimization horizon).
|
||||
window_slots: Desired window length; a shorter horizon yields less.
|
||||
|
||||
Returns:
|
||||
The window as a float array, empty when no data is available.
|
||||
"""
|
||||
if values is None:
|
||||
return np.zeros(0, dtype=float)
|
||||
end = min(int(end_slot), len(values))
|
||||
start = max(end - int(window_slots), 0)
|
||||
if end <= start:
|
||||
return np.zeros(0, dtype=float)
|
||||
return np.asarray(values[start:end], dtype=float)
|
||||
@@ -1,7 +1,7 @@
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import Field, computed_field
|
||||
from pydantic import Field, computed_field, model_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
@@ -10,7 +10,10 @@ from akkudoktoreos.core.pydantic import (
|
||||
PydanticDateTimeDataFrame,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic0.genetic0settings import Genetic0CommonSettings
|
||||
from akkudoktoreos.optimization.genetic.geneticsettings import GeneticCommonSettings
|
||||
from akkudoktoreos.optimization.genetic.geneticsettings import (
|
||||
GeneticCommonSettings,
|
||||
normalize_genetic_settings,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime
|
||||
|
||||
|
||||
@@ -29,6 +32,12 @@ def optimization_default_algorithm() -> OptimizationAlgorithm:
|
||||
class OptimizationCommonSettings(SettingsBaseModel):
|
||||
"""General Optimization Configuration."""
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_old_genetic_settings(cls, value: Any) -> Any:
|
||||
"""Accept the feature branch's flat GENETIC settings without losing values."""
|
||||
return normalize_genetic_settings(value)
|
||||
|
||||
algorithm: OptimizationAlgorithm = Field(
|
||||
default_factory=optimization_default_algorithm,
|
||||
json_schema_extra={
|
||||
|
||||
@@ -42,6 +42,14 @@ def feedintariff_provider_ids() -> list[str]:
|
||||
class FeedInTariffCommonSettings(SettingsBaseModel):
|
||||
"""Feed In Tariff Prediction Configuration."""
|
||||
|
||||
direct_marketing_enabled: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Enable export-aware GENETIC optimization. Sale revenues remain those of the "
|
||||
"configured feed-in provider or explicit forecast; purchase prices never replace them."
|
||||
),
|
||||
)
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
|
||||
@@ -64,7 +64,11 @@ from akkudoktoreos.optimization.genetic0.genetic0solution import (
|
||||
from akkudoktoreos.optimization.genetic0.genetic0visualize import (
|
||||
genetic0_prepare_visualize,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.optimization.genetic.geneticvisualize import (
|
||||
genetic_prepare_visualize,
|
||||
)
|
||||
from akkudoktoreos.optimization.optimization import (
|
||||
OptimizationAlgorithm,
|
||||
OptimizationSolution,
|
||||
@@ -1867,6 +1871,35 @@ async def fastapi_energy_management_optimization_solution_algorithm_get(
|
||||
return solution
|
||||
|
||||
|
||||
@app.get(
|
||||
"/v1/energy-management/optimization/solution/GENETIC/pdf",
|
||||
tags=["energy-management"],
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}}},
|
||||
)
|
||||
async def fastapi_energy_management_optimization_solution_genetic_pdf_get() -> Response:
|
||||
"""Render the retained GENETIC result without rerunning optimization.
|
||||
|
||||
Rendering runs outside the event loop. Copy the result before offloading;
|
||||
its recorded timestamp, interval and inputs own the report's time grid.
|
||||
The legacy /visualization_results.pdf route continues to serve GENETIC0.
|
||||
"""
|
||||
retained = get_ems().genetic_solution()
|
||||
if retained is None:
|
||||
raise EOSProblem(
|
||||
status=404,
|
||||
title="Optimization solution report retrieval failed",
|
||||
detail="Can not get the 'GENETIC' optimization solution.",
|
||||
)
|
||||
snapshot = retained.model_copy(deep=True)
|
||||
pdf = await asyncio.to_thread(genetic_prepare_visualize, solution=snapshot)
|
||||
return Response(
|
||||
content=pdf,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": 'inline; filename="optimization-genetic.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/energy-management/plan", tags=["energy-management"])
|
||||
def fastapi_energy_management_plan_get() -> EnergyManagementPlan:
|
||||
"""Get the latest energy management plan."""
|
||||
@@ -2174,6 +2207,44 @@ async def fastapi_pvforecast() -> ForecastResponse:
|
||||
return ForecastResponse(temperature=temp_air_list, pvpower=ac_power_list)
|
||||
|
||||
|
||||
@app.post("/v1/optimize", tags=["optimize"])
|
||||
async def fastapi_optimize_config(
|
||||
request: Request,
|
||||
parameters: ConfigOptimizationRequest = Body(default_factory=ConfigOptimizationRequest),
|
||||
) -> GeneticSolution:
|
||||
"""Optimize GENETIC using configured devices and optional fresh runtime inputs.
|
||||
|
||||
Static settings belong in configuration; query overrides are rejected.
|
||||
Forecast arrays start at local
|
||||
midnight and contain Wh per configured GENETIC slot; prices are currency/Wh.
|
||||
An empty body uses configured providers and fresh measured states of charge.
|
||||
The deprecated /optimize endpoint continues to run hourly GENETIC0.
|
||||
"""
|
||||
if request.query_params:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Configure optimization settings; query overrides are not supported.",
|
||||
)
|
||||
solution = await get_ems().run(
|
||||
mode=EnergyManagementMode.OPTIMIZATION,
|
||||
algorithm=OptimizationAlgorithm.GENETIC,
|
||||
genetic_parameters=parameters,
|
||||
)
|
||||
if solution is None:
|
||||
raise EOSProblem(
|
||||
status=503,
|
||||
title="GENETIC optimization failed",
|
||||
detail="No new solution was produced. Check configured devices, fresh SoC and forecast coverage.",
|
||||
)
|
||||
if not isinstance(solution, GeneticSolution):
|
||||
raise EOSProblem(
|
||||
status=500,
|
||||
title="Unexpected optimization algorithm result",
|
||||
detail="GENETIC did not return its native solution type.",
|
||||
)
|
||||
return solution
|
||||
|
||||
|
||||
@app.post("/optimize", tags=["optimize"])
|
||||
async def fastapi_optimize(
|
||||
parameters: Genetic0OptimizationParameters,
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Configuration-owned requests and the real automatic GENETIC path."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.config.configmigrate import migrate_config_data
|
||||
from akkudoktoreos.core.ems import EnergyManagement, EnergyManagementStage
|
||||
from akkudoktoreos.core.emsettings import EnergyManagementMode
|
||||
from akkudoktoreos.optimization.genetic import configrequest
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.optimization import (
|
||||
OptimizationAlgorithm,
|
||||
OptimizationCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configured_request(config_eos, monkeypatch):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 24},
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"genetic": {
|
||||
"interval_sec": 900,
|
||||
"horizon_hours": 1,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
"terminal_value_euro_per_kwh": 0.23,
|
||||
"individuals": 10,
|
||||
"generations": 10,
|
||||
"seed": 42,
|
||||
},
|
||||
},
|
||||
"devices": {
|
||||
"max_batteries": 1,
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 1,
|
||||
"max_home_appliances": 2,
|
||||
"batteries": {
|
||||
"storage": {
|
||||
"capacity_wh": 2000,
|
||||
"max_charge_power_w": 1000,
|
||||
"levelized_cost_of_storage_amt_kwh": 0.01,
|
||||
}
|
||||
},
|
||||
"electric_vehicles": {},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
|
||||
"home_appliances": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
start = to_datetime("2026-09-12T10:15:00Z", in_timezone="UTC")
|
||||
ems = SimpleNamespace(
|
||||
start_datetime=start, observation_datetime=start, genetic_solution=lambda: None
|
||||
)
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
measurement = Mock(key_to_lists=AsyncMock(return_value=([], [])))
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "measurement", measurement)
|
||||
data = {
|
||||
"soc": {"storage": 42},
|
||||
"forecasts": {
|
||||
"pv_forecast_wh": [100.0] * 96,
|
||||
"total_load": [200.0] * 96,
|
||||
"electricity_price_per_wh": [0.0003] * 96,
|
||||
"feed_in_tariff_per_wh": [0.00008] * 96,
|
||||
},
|
||||
}
|
||||
return config_eos, ems, measurement, data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hardware_costs_and_state_are_resolved_without_config_changes(configured_request):
|
||||
config, _, _, data = configured_request
|
||||
before = config.model_dump_json()
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.device_id == "storage"
|
||||
assert parameters.pv_battery.capacity_wh == 2000
|
||||
assert parameters.pv_battery.initial_soc_percentage == 42
|
||||
assert parameters.pv_battery.levelized_cost_of_storage_kwh == 0.01
|
||||
assert parameters.ems.price_per_wh_battery == pytest.approx(0.00023)
|
||||
assert parameters.forecast_interval_seconds == 900
|
||||
assert config.model_dump_json() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["devices", "optimization", "pv_battery", "inverter", "ems"])
|
||||
def test_static_http_overrides_are_rejected(field):
|
||||
with pytest.raises(ValidationError):
|
||||
ConfigOptimizationRequest.model_validate({field: {}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_timezone", ["UTC", "Europe/Berlin"])
|
||||
def test_warmstart_timestamp_retains_explicit_zone_and_json_instant(
|
||||
set_other_timezone, host_timezone
|
||||
):
|
||||
set_other_timezone(host_timezone)
|
||||
previous = to_datetime("2026-10-25T02:30:00+01:00", in_timezone="Europe/Berlin")
|
||||
request = ConfigOptimizationRequest(start_solution_datetime=previous)
|
||||
assert request.start_solution_datetime is not None
|
||||
assert request.start_solution_datetime.timezone_name == "Europe/Berlin"
|
||||
assert request.start_solution_datetime.timestamp() == previous.timestamp()
|
||||
restored = ConfigOptimizationRequest.model_validate_json(request.model_dump_json())
|
||||
assert restored.start_solution_datetime is not None
|
||||
assert restored.start_solution_datetime.timestamp() == previous.timestamp()
|
||||
assert restored.start_solution_datetime.utcoffset() == previous.utcoffset()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("age,value", [(301, 0.45), (-1, 0.45), (1, None), (1, np.nan), (1, 1.1)])
|
||||
async def test_missing_stale_future_or_invalid_soc_never_becomes_zero(
|
||||
configured_request, age, value
|
||||
):
|
||||
_, ems, measurement, data = configured_request
|
||||
data["soc"] = {}
|
||||
measurement.key_to_lists.return_value = (
|
||||
[ems.observation_datetime.subtract(seconds=age)],
|
||||
[value],
|
||||
)
|
||||
with pytest.raises(ValueError, match="Fresh SoC missing"):
|
||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soc_freshness_uses_actual_run_time_inside_quarter_hour(configured_request):
|
||||
_, ems, measurement, data = configured_request
|
||||
data["soc"] = {}
|
||||
ems.observation_datetime = ems.start_datetime.add(minutes=7)
|
||||
measurement.key_to_lists.return_value = (
|
||||
[ems.observation_datetime.subtract(seconds=30)],
|
||||
[0.456],
|
||||
)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.initial_soc_percentage == 45
|
||||
assert measurement.key_to_lists.call_args.kwargs[
|
||||
"end_datetime"
|
||||
] == ems.observation_datetime.add(seconds=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_future_soc_in_repeated_hour_is_rejected(configured_request):
|
||||
_, ems, measurement, data = configured_request
|
||||
data["soc"] = {}
|
||||
ems.observation_datetime = to_datetime("2026-10-25T02:45:00+02:00", in_timezone="Europe/Berlin")
|
||||
measurement.key_to_lists.return_value = (
|
||||
[to_datetime("2026-10-25T02:15:00+01:00", in_timezone="Europe/Berlin")],
|
||||
[0.8],
|
||||
)
|
||||
with pytest.raises(ValueError, match="Fresh SoC missing"):
|
||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_soc_and_mismatched_device_link_are_rejected(configured_request):
|
||||
config, _, _, data = configured_request
|
||||
request = ConfigOptimizationRequest.model_validate(data)
|
||||
request.soc["unknown"] = 20
|
||||
with pytest.raises(ValueError, match="unconfigured"):
|
||||
await request.resolve()
|
||||
assert config.devices.inverters is not None
|
||||
config.devices.inverters["inverter"].battery_id = "missing"
|
||||
with pytest.raises(ValueError, match="battery_id"):
|
||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tariff", [0.00008, 0.0, -0.00005])
|
||||
async def test_direct_marketing_keeps_explicit_imported_sale_prices(configured_request, tariff):
|
||||
config, _, _, data = configured_request
|
||||
config.feedintariff.direct_marketing_enabled = True
|
||||
config.feedintariff.provider = "FeedInTariffImport"
|
||||
data["forecasts"]["feed_in_tariff_per_wh"] = [tariff] * 96
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.ems.feed_in_tariff_per_wh == [tariff] * 96
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_gaps_fail_but_shorter_tail_is_accepted(configured_request):
|
||||
_, _, _, data = configured_request
|
||||
data["forecasts"]["electricity_price_per_wh"] = [0.0003] * 50
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert len(parameters.ems.total_load) == 50
|
||||
data["forecasts"]["electricity_price_per_wh"][42] = np.nan
|
||||
with pytest.raises(ValueError, match="control horizon"):
|
||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_power_is_integrated_once_and_update_is_awaited(
|
||||
configured_request, monkeypatch
|
||||
):
|
||||
_, _, _, data = configured_request
|
||||
data["forecasts"] = {}
|
||||
series = {
|
||||
"pvforecast_ac_power": 1000.0,
|
||||
"loadforecast_power_w": 2000.0,
|
||||
"elecprice_marketprice_wh": 0.0003,
|
||||
"feed_in_tariff_wh": -0.00005,
|
||||
}
|
||||
|
||||
async def read(key, **kwargs):
|
||||
return pd.Series(
|
||||
[series[key]] * 48, index=pd.date_range("2026-09-12T00:00:00Z", periods=48, freq="h")
|
||||
)
|
||||
|
||||
prediction = Mock(update_data=AsyncMock(), key_to_raw_series=AsyncMock(side_effect=read))
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "prediction", prediction)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.ems.pv_forecast_wh == [250.0] * len(parameters.ems.pv_forecast_wh)
|
||||
assert parameters.ems.total_load == [500.0] * len(parameters.ems.total_load)
|
||||
assert parameters.ems.feed_in_tariff_per_wh == [-0.00005] * len(parameters.ems.total_load)
|
||||
prediction.update_data.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stamp,interval,expected",
|
||||
[
|
||||
("2026-03-29T03:17:00+02:00", 900, "2026-03-29T03:15:00+02:00"),
|
||||
("2026-10-25T02:47:00+01:00", 900, "2026-10-25T02:45:00+01:00"),
|
||||
("2026-10-25T02:47:00+01:00", 3600, "2026-10-25T02:00:00+01:00"),
|
||||
],
|
||||
)
|
||||
def test_slot_alignment_preserves_dst_fold(config_eos, stamp, interval, expected):
|
||||
time = to_datetime(stamp, in_timezone="Europe/Berlin")
|
||||
aligned = EnergyManagement.set_start_datetime(time, interval_seconds=interval)
|
||||
assert aligned == to_datetime(expected, in_timezone="Europe/Berlin")
|
||||
assert aligned.utcoffset() == to_datetime(expected, in_timezone="Europe/Berlin").utcoffset()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timezone", ["Asia/Kolkata", "Asia/Kathmandu"])
|
||||
@pytest.mark.parametrize("interval,minute", [(900, 30), (3600, 0)])
|
||||
def test_slot_alignment_uses_local_midnight(config_eos, timezone, interval, minute):
|
||||
time = to_datetime("2026-09-12T10:40:00", in_timezone=timezone)
|
||||
aligned = EnergyManagement.set_start_datetime(time, interval_seconds=interval)
|
||||
assert aligned.hour == 10
|
||||
assert aligned.minute == minute
|
||||
assert aligned.timezone_name == timezone
|
||||
assert EnergyManagement().observation_datetime == time
|
||||
|
||||
|
||||
def test_old_feature_config_migrates_without_changing_explicit_nested_values(config_eos):
|
||||
raw = {
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"interval": 900,
|
||||
"horizon_hours": 12,
|
||||
"terminal_value_euro_per_kwh": 0.23,
|
||||
"genetic": {"horizon_hours": 8},
|
||||
}
|
||||
}
|
||||
migrated = migrate_config_data(raw)
|
||||
assert migrated.optimization.genetic.interval_sec == 900
|
||||
assert migrated.optimization.genetic.horizon_hours == 8
|
||||
assert migrated.optimization.genetic.terminal_value_euro_per_kwh == 0.23
|
||||
settings = OptimizationCommonSettings.model_validate(raw["optimization"])
|
||||
settings.algorithm = OptimizationAlgorithm.GENETIC0
|
||||
assert settings.genetic.interval_sec == 900
|
||||
assert settings.genetic.generations == 400
|
||||
|
||||
|
||||
def test_http_empty_body_is_configuration_request_and_failure_cannot_return_cache(monkeypatch):
|
||||
from akkudoktoreos.server import eos
|
||||
|
||||
run = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(run=run))
|
||||
client = TestClient(eos.app)
|
||||
response = client.post("/v1/optimize")
|
||||
assert response.status_code == 503
|
||||
assert isinstance(run.call_args.kwargs["genetic_parameters"], ConfigOptimizationRequest)
|
||||
assert run.call_args.kwargs["algorithm"] == OptimizationAlgorithm.GENETIC
|
||||
response = client.post("/v1/optimize", json={"devices": {}})
|
||||
assert response.status_code == 422
|
||||
assert run.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["start_hour=4", "ngen=1", "interval=3600", "unknown="])
|
||||
def test_http_configuration_request_rejects_query_overrides(monkeypatch, query):
|
||||
from akkudoktoreos.server import eos
|
||||
|
||||
run = AsyncMock()
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(run=run))
|
||||
response = TestClient(eos.app).post(f"/v1/optimize?{query}", json={})
|
||||
assert response.status_code == 422
|
||||
assert "query overrides are not supported" in response.text
|
||||
run.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("automatic", [False, True])
|
||||
async def test_real_ems_returns_coherent_quarter_hour_solution_and_plan(
|
||||
configured_request, monkeypatch, automatic
|
||||
):
|
||||
_, fake, _, data = configured_request
|
||||
ems = EnergyManagement()
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
monkeypatch.setattr(EnergyManagement, "prediction", Mock(update_data=AsyncMock()))
|
||||
monkeypatch.setattr(EnergyManagement, "adapter", Mock(update_data=AsyncMock()))
|
||||
request = ConfigOptimizationRequest.model_validate(data)
|
||||
if automatic:
|
||||
original = ConfigOptimizationRequest.resolve
|
||||
|
||||
async def resolve_default(self):
|
||||
return await original(request)
|
||||
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "resolve", resolve_default)
|
||||
solution = await ems.run(
|
||||
start_datetime=fake.start_datetime.add(minutes=2),
|
||||
mode=EnergyManagementMode.OPTIMIZATION,
|
||||
genetic_parameters=None if automatic else request,
|
||||
genetic_generations=10,
|
||||
genetic_seed=42,
|
||||
)
|
||||
assert solution is not None
|
||||
assert solution is ems.genetic_solution()
|
||||
assert solution.start_solution_datetime == fake.start_datetime
|
||||
assert len(solution.ac_charge) == 4
|
||||
assert ems.optimization_solution() is not None
|
||||
assert ems.plan() is not None
|
||||
assert ems.stage() == EnergyManagementStage.IDLE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_automatic_run_reads_real_resolver_provider_units_and_measured_soc(
|
||||
configured_request, monkeypatch
|
||||
):
|
||||
_, fake, measurement, _ = configured_request
|
||||
ems = EnergyManagement()
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
monkeypatch.setattr(EnergyManagement, "_genetic_solution", None)
|
||||
measurement.key_to_lists.return_value = ([fake.start_datetime], [0.42])
|
||||
values = {
|
||||
"pvforecast_ac_power": 1000.0,
|
||||
"loadforecast_power_w": 2000.0,
|
||||
"elecprice_marketprice_wh": 0.0003,
|
||||
"feed_in_tariff_wh": 0.00008,
|
||||
}
|
||||
|
||||
async def read(key, **kwargs):
|
||||
return pd.Series(
|
||||
[values[key]] * 48, index=pd.date_range("2026-09-12T00:00:00Z", periods=48, freq="h")
|
||||
)
|
||||
|
||||
prediction = Mock(update_data=AsyncMock(), key_to_raw_series=AsyncMock(side_effect=read))
|
||||
monkeypatch.setattr(EnergyManagement, "prediction", prediction)
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "prediction", prediction)
|
||||
monkeypatch.setattr(EnergyManagement, "adapter", Mock(update_data=AsyncMock()))
|
||||
solution = await ems.run(
|
||||
start_datetime=fake.start_datetime,
|
||||
mode=EnergyManagementMode.OPTIMIZATION,
|
||||
genetic_generations=10,
|
||||
genetic_seed=42,
|
||||
)
|
||||
assert solution is not None
|
||||
assert solution.parameters is not None
|
||||
assert solution.parameters.pv_battery is not None
|
||||
assert solution.parameters.pv_battery.initial_soc_percentage == 42
|
||||
assert solution.parameters.ems.pv_forecast_wh[:4] == [250.0] * 4
|
||||
assert solution.parameters.ems.total_load[:4] == [500.0] * 4
|
||||
assert len(solution.ac_charge) == 4
|
||||
prediction.key_to_raw_series.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_real_genetic_result_serializes_native_quarter_hour_contract(
|
||||
configured_request, monkeypatch
|
||||
):
|
||||
from akkudoktoreos.server import eos
|
||||
|
||||
_, fake, _, data = configured_request
|
||||
ems = EnergyManagement()
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
monkeypatch.setattr(EnergyManagement, "_genetic_solution", None)
|
||||
monkeypatch.setattr(EnergyManagement, "prediction", Mock(update_data=AsyncMock()))
|
||||
monkeypatch.setattr(EnergyManagement, "adapter", Mock(update_data=AsyncMock()))
|
||||
|
||||
async def run(**kwargs):
|
||||
return await ems.run(start_datetime=fake.start_datetime, **kwargs)
|
||||
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(run=run))
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=eos.app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.post("/v1/optimize", json=data)
|
||||
assert response.status_code == 200, response.text
|
||||
result = response.json()
|
||||
assert result["interval_seconds"] == 900
|
||||
assert result["controls_start_at_now"] is True
|
||||
assert len(result["ac_charge"]) == 4
|
||||
assert result["parameters"]["pv_battery"]["device_id"] == "storage"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Verify fallback JSON loading does not lose records through the singleton."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_measurement
|
||||
from akkudoktoreos.core.dataabc import DataSequence
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_measurement_json_roundtrip(config_eos, tmp_path, monkeypatch):
|
||||
m = get_measurement()
|
||||
config_eos.measurement.load_emr_keys = ["meter"]
|
||||
config_eos.general.data_folder_path = tmp_path
|
||||
config_eos.database.provider = None
|
||||
m._db_reset_state()
|
||||
try:
|
||||
await m.update_value(to_datetime("2026-09-16T08:00:00Z"), "meter", 123.5)
|
||||
monkeypatch.setattr(DataSequence, "save", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(DataSequence, "load", AsyncMock(return_value=False))
|
||||
assert await m.save()
|
||||
m._db_reset_state()
|
||||
assert await m.load()
|
||||
assert len(m.records) == 1
|
||||
assert m.records[0]["meter"] == 123.5
|
||||
finally:
|
||||
m._db_reset_state()
|
||||
@@ -994,10 +994,11 @@ class TestDataSequence:
|
||||
data_dict_keep = await sequence.key_to_dict("data_value", dropna=False)
|
||||
assert pd.isna(data_dict_keep[to_datetime(datetime(2023, 11, 6), as_string=True)])
|
||||
|
||||
async def test_key_to_lists_dropna_removes_nan(self, sequence):
|
||||
@pytest.mark.parametrize("missing", [None, float("nan")])
|
||||
async def test_key_to_lists_dropna_removes_nan(self, sequence, missing):
|
||||
"""`dropna=True` (default) must drop records whose value is NaN, not just None."""
|
||||
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
|
||||
record2 = self.create_test_record(datetime(2023, 11, 6), float("nan"))
|
||||
record2 = self.create_test_record(datetime(2023, 11, 6), missing)
|
||||
record3 = self.create_test_record(datetime(2023, 11, 7), 0.9)
|
||||
await sequence.insert_by_datetime(record1)
|
||||
await sequence.insert_by_datetime(record2)
|
||||
@@ -1013,6 +1014,27 @@ class TestDataSequence:
|
||||
assert len(values_keep) == 3
|
||||
assert pd.isna(values_keep[1])
|
||||
|
||||
async def test_raw_none_record_keeps_forecast_gap_and_true_interval_length(self, sequence):
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
|
||||
start = to_datetime("2026-09-16T10:00:00Z", in_timezone="UTC")
|
||||
for minute, value in [(0, 100.0), (15, None), (30, 100.0)]:
|
||||
await sequence.insert_by_datetime(
|
||||
self.create_test_record(start.add(minutes=minute), value)
|
||||
)
|
||||
raw = await sequence.key_to_raw_series("data_value", dropna=False)
|
||||
assert len(raw) == 3
|
||||
assert raw.index[1].timestamp() == start.add(minutes=15).timestamp()
|
||||
assert pd.isna(raw.iloc[1])
|
||||
filtered = await sequence.key_to_raw_series("data_value")
|
||||
assert filtered.tolist() == [100.0, 100.0]
|
||||
values = await bounded_forecast_array(
|
||||
sequence, key="data_value", start_datetime=start,
|
||||
end_datetime=start.add(hours=1), interval=to_duration(900),
|
||||
)
|
||||
assert values[[0, 2]].tolist() == [100.0, 100.0]
|
||||
assert np.isnan(values[[1, 3]]).all()
|
||||
|
||||
async def test_to_dataframe_full_data(self, sequence):
|
||||
"""Test conversion of all records to a DataFrame without filtering."""
|
||||
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Repeated local clock times must dispatch by their real UTC instant."""
|
||||
|
||||
from akkudoktoreos.core.emplan import EnergyManagementPlan, FRBCInstruction
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
def test_repeated_hour_keeps_instruction_order_and_dispatch():
|
||||
first = to_datetime("2026-10-25T02:45:00+02:00").in_timezone("Europe/Berlin")
|
||||
second = to_datetime("2026-10-25T02:15:00+01:00").in_timezone("Europe/Berlin")
|
||||
plan = EnergyManagementPlan(id="fold", generated_at=first, instructions=[])
|
||||
later = FRBCInstruction(
|
||||
resource_id="battery1",
|
||||
actuator_id="battery1",
|
||||
execution_time=second,
|
||||
operation_mode_id="IDLE",
|
||||
operation_mode_factor=1.0,
|
||||
)
|
||||
earlier = FRBCInstruction(
|
||||
resource_id="battery1",
|
||||
actuator_id="battery1",
|
||||
execution_time=first,
|
||||
operation_mode_id="GRID_SUPPORT_IMPORT",
|
||||
operation_mode_factor=0.5,
|
||||
)
|
||||
plan.add_instruction(later)
|
||||
plan.add_instruction(earlier)
|
||||
assert [item.execution_time.timestamp() for item in plan.instructions] == [
|
||||
first.timestamp(),
|
||||
second.timestamp(),
|
||||
]
|
||||
assert plan.valid_from is not None
|
||||
assert plan.valid_from.timestamp() == first.timestamp()
|
||||
assert plan.valid_until is None
|
||||
between = to_datetime("2026-10-25T02:00:00+01:00").in_timezone("Europe/Berlin")
|
||||
assert plan.get_active_instructions(between) == [earlier]
|
||||
assert plan.get_next_instruction(between) == later
|
||||
assert plan.get_active_instructions(second) == [later]
|
||||
assert plan.get_next_instruction(second) is None
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Profiles, cycle windows and deadlines on the GENETIC slot grid."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.devices.devicesabc import (
|
||||
ConsumerDeadlinePolicy,
|
||||
ConsumerScheduleMode,
|
||||
)
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, ElectricVehicleParameters
|
||||
from akkudoktoreos.devices.genetic.homeappliance import (
|
||||
HomeAppliance,
|
||||
HomeApplianceParameters,
|
||||
resample_power_to_slot_energy,
|
||||
)
|
||||
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
|
||||
from akkudoktoreos.devices.settings.homeappliancesettings import (
|
||||
HomeApplianceCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
def appliance(*, slots: int = 192, slot_h: float = 0.25, **kwargs: Any) -> HomeAppliance:
|
||||
return HomeAppliance(
|
||||
HomeApplianceParameters.model_validate(
|
||||
{
|
||||
"device_id": "washer",
|
||||
"load_profile_power_w": [1200.0, 600.0, 300.0],
|
||||
"load_profile_interval_seconds": 600,
|
||||
**kwargs,
|
||||
}
|
||||
),
|
||||
optimization_hours=48,
|
||||
prediction_hours=slots,
|
||||
slot_duration_h=slot_h,
|
||||
)
|
||||
|
||||
|
||||
def test_noninteger_resampling_conserves_real_energy() -> None:
|
||||
# 10-minute 1.2kW/0.6kW/0.3kW phases: 350Wh, delivered in two 15min slots.
|
||||
device = appliance()
|
||||
np.testing.assert_allclose(device.run_energy_wh, [250.0, 100.0])
|
||||
device.build_load_curve([3, 12])
|
||||
assert device.get_load_curve().sum() == pytest.approx(700)
|
||||
np.testing.assert_allclose(device.get_load_curve()[3:5], [250.0, 100.0])
|
||||
assert device.run_slots == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_s,slot_s", [(600, 900), (1200, 900), (900, 3600), (3600, 900)])
|
||||
def test_profile_energy_independent_of_slot_grid(input_s: int, slot_s: int) -> None:
|
||||
result = resample_power_to_slot_energy([400.0, 1600.0, 200.0], input_s, slot_s)
|
||||
assert result.sum() == pytest.approx(2200 * input_s / 3600)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", [[], [-1], [float("nan")], [float("inf")]])
|
||||
def test_invalid_profiles_rejected_in_parameters_and_settings(profile: list[float]) -> None:
|
||||
for model in (HomeApplianceParameters, HomeApplianceCommonSettings):
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"device_id": "bad", "load_profile_power_w": profile})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fields", [{"duration_h": 2}, {"consumption_wh": 500}, {"duration_h": 2, "consumption_wh": 500}]
|
||||
)
|
||||
def test_profile_cannot_silently_override_explicit_flat_definition(fields: dict[str, int]) -> None:
|
||||
for model in (HomeApplianceParameters, HomeApplianceCommonSettings):
|
||||
with pytest.raises(ValidationError, match="Conflicting"):
|
||||
model.model_validate({"device_id": "bad", "load_profile_power_w": [100.0], **fields})
|
||||
|
||||
|
||||
def test_settings_profile_roundtrip_and_legacy_defaults() -> None:
|
||||
defaults = HomeApplianceCommonSettings(device_id="legacy")
|
||||
assert defaults.to_genetic_param().consumption_wh == 3000
|
||||
assert defaults.to_genetic0_param().duration_h == 3
|
||||
settings = HomeApplianceCommonSettings.model_validate(
|
||||
{
|
||||
"device_id": "washer",
|
||||
"load_profile_power_w": [1200.0, 600.0],
|
||||
"load_profile_interval_seconds": 600,
|
||||
"schedule_mode": "DAILY",
|
||||
"num_cycles": 2,
|
||||
"min_cycle_gap_h": 1,
|
||||
"time_windows": {"windows": [{"start_time": "07:00", "duration": "12 hours"}]},
|
||||
}
|
||||
)
|
||||
restored = HomeApplianceCommonSettings.model_validate_json(settings.model_dump_json())
|
||||
params = restored.to_genetic_param()
|
||||
assert params.load_profile_power_w == [1200.0, 600.0]
|
||||
assert params.consumption_wh is None and params.duration_h is None
|
||||
assert params.num_cycles == 2 and params.min_cycle_gap_h == 1
|
||||
assert params.schedule_mode == ConsumerScheduleMode.DAILY
|
||||
assert params.shared_time_windows is not None
|
||||
with pytest.raises(ValueError, match="GENETIC"):
|
||||
restored.to_genetic0_param()
|
||||
|
||||
|
||||
def test_cycle_and_shared_windows_intersect_after_completed_cycle() -> None:
|
||||
settings = HomeApplianceCommonSettings.model_validate(
|
||||
{
|
||||
"device_id": "washer",
|
||||
"load_profile_power_w": [1200.0],
|
||||
"load_profile_interval_seconds": 1800,
|
||||
"schedule_mode": "DAILY",
|
||||
"cycle_time_windows": {
|
||||
"windows": [
|
||||
{"start_time": "06:00", "duration": "2 hours", "value": 0},
|
||||
{"start_time": "18:00", "duration": "2 hours", "value": 1},
|
||||
]
|
||||
},
|
||||
"time_windows": {"windows": [{"start_time": "18:30", "duration": "1 hour"}]},
|
||||
}
|
||||
)
|
||||
device = HomeAppliance(settings.to_genetic_param(), 48, 192, 0.25)
|
||||
device.set_completed_cycles(1)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.remaining_cycle_indices == [1]
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96, cycle_index=1
|
||||
) == [74, 75, 76]
|
||||
assert (
|
||||
device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96, cycle_index=0
|
||||
)
|
||||
== []
|
||||
)
|
||||
# Next-day DAILY lookup remains possible using absolute configured cycle IDs.
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=96, horizon_end_slot=192, cycle_index=1
|
||||
) == [170, 171, 172]
|
||||
|
||||
|
||||
def test_legacy_multiple_cycles_use_slots_and_physical_idle_gap() -> None:
|
||||
device = appliance(slots=96, num_cycles=2, min_cycle_gap_h=1)
|
||||
assert device.set_starting_times([4, 4]) == [4, 10]
|
||||
assert device.get_load_curve().sum() == pytest.approx(700)
|
||||
|
||||
|
||||
def test_touching_cycle_windows_preserve_union() -> None:
|
||||
device = appliance(
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "08:00", "duration": "15 minutes", "value": 0},
|
||||
{"start_time": "08:15", "duration": "15 minutes", "value": 0},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin"),
|
||||
earliest_slot=0,
|
||||
horizon_end_slot=96,
|
||||
cycle_index=0,
|
||||
) == [32]
|
||||
|
||||
|
||||
def test_absolute_bounds_round_inward_on_quarter_hour_grid() -> None:
|
||||
device = appliance(
|
||||
earliest_start_datetime="2026-09-16T08:01:00+02:00",
|
||||
deadline_datetime="2026-09-16T09:01:00+02:00",
|
||||
deadline_policy="STRICT",
|
||||
)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96
|
||||
) == [33, 34]
|
||||
assert not device.deadline_missed([34], zero)
|
||||
assert device.deadline_missed([35], zero)
|
||||
|
||||
|
||||
def test_best_effort_relaxes_only_deadline_not_window_or_earliest() -> None:
|
||||
device = appliance(
|
||||
deadline_datetime="2026-09-16T08:00:00+02:00",
|
||||
shared_time_windows={"windows": [{"start_time": "09:00", "duration": "1 hour"}]},
|
||||
)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96
|
||||
) == [36]
|
||||
assert device.deadline_relaxed and device.deadline_missed([36], zero)
|
||||
device.deadline_policy = ConsumerDeadlinePolicy.STRICT
|
||||
assert (
|
||||
device.allowed_start_slots(slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96) == []
|
||||
)
|
||||
assert not device.deadline_relaxed
|
||||
|
||||
|
||||
def test_overnight_window_uses_opening_date_and_weekday() -> None:
|
||||
device = appliance(
|
||||
shared_time_windows={
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "23:00",
|
||||
"duration": "3 hours",
|
||||
"date": "2026-09-15",
|
||||
"day_of_week": 1,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96
|
||||
) == list(range(7))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("date,expected", [("2026-03-29", 8), ("2026-10-25", 16)])
|
||||
def test_dst_elapsed_slots_and_local_window(date: str, expected: int) -> None:
|
||||
zero = to_datetime(f"{date}T00:00:00", in_timezone="Europe/Berlin")
|
||||
device = appliance(
|
||||
shared_time_windows={"windows": [{"start_time": "03:00", "duration": "30 minutes"}]}
|
||||
)
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=100
|
||||
) == [expected]
|
||||
assert device.run_end_datetime(expected, zero).hour == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("starts", [[-1], [191], [192]])
|
||||
def test_complete_curve_does_not_silently_truncate_runs(starts: list[int]) -> None:
|
||||
with pytest.raises(ValueError, match="complete"):
|
||||
appliance().build_load_curve(starts)
|
||||
|
||||
|
||||
def test_ev_deadlines_roundtrip_without_changing_slot_physics() -> None:
|
||||
settings = BatteriesCommonSettings.model_validate(
|
||||
{
|
||||
"device_id": "car",
|
||||
"capacity_wh": 10000,
|
||||
"charging_efficiency": 0.8,
|
||||
"max_charge_power_w": 4000,
|
||||
"min_soc_deadline_datetime": "2026-09-16T07:30:00+02:00",
|
||||
"min_soc_max_duration_h": 2.5,
|
||||
}
|
||||
)
|
||||
params = settings.to_genetic_ev_bat_param()
|
||||
assert params.min_soc_max_duration_h == 2.5
|
||||
assert params.min_soc_deadline_datetime is not None
|
||||
assert params.min_soc_deadline_datetime.in_timezone("Europe/Berlin").hour == 7
|
||||
battery = Battery(params, prediction_hours=16, slot_duration_h=0.25)
|
||||
battery.charge_array[0] = 1
|
||||
charged, losses = battery.charge_energy(2000, hour=0)
|
||||
assert charged == pytest.approx(800)
|
||||
assert losses == pytest.approx(200)
|
||||
assert "min_soc_deadline_datetime" not in settings.to_genetic0_ev_bat_param().model_dump()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duration", [0.0, -1.0, float("inf"), float("nan")])
|
||||
def test_ev_invalid_departure_durations_rejected(duration: float) -> None:
|
||||
for model in (ElectricVehicleParameters, BatteriesCommonSettings):
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate(
|
||||
{"device_id": "car", "capacity_wh": 10000, "min_soc_max_duration_h": duration}
|
||||
)
|
||||
|
||||
|
||||
def test_completed_cycles_parameter_initializes_remaining_global_ids() -> None:
|
||||
device = appliance(num_cycles=3, completed_cycles=2)
|
||||
assert device.completed_cycles == 2
|
||||
assert device.remaining_cycle_indices == [2]
|
||||
assert device.num_remaining_cycles == 1
|
||||
done = appliance(num_cycles=3, completed_cycles=3)
|
||||
assert done.remaining_cycle_indices == []
|
||||
with pytest.raises(ValidationError, match="completed_cycles"):
|
||||
appliance(num_cycles=2, completed_cycles=3)
|
||||
|
||||
|
||||
def test_best_effort_multiple_cycles_retain_room_for_joint_gap_repair() -> None:
|
||||
device = appliance(
|
||||
num_cycles=2,
|
||||
min_cycle_gap_h=1,
|
||||
deadline_datetime="2026-09-16T08:00:00+02:00",
|
||||
shared_time_windows={"windows": [{"start_time": "09:00", "duration": "3 hours"}]},
|
||||
)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96, cycle_index=0
|
||||
) == list(range(36, 47))
|
||||
assert device.deadline_relaxed
|
||||
device.build_load_curve([36, 42])
|
||||
assert device.get_load_curve().sum() == pytest.approx(700)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deadline", ["2026-09-16T07:00:00Z", "2026-09-16T09:00:00+02:00"])
|
||||
def test_absolute_deadline_offsets_describe_same_instant(deadline: str) -> None:
|
||||
device = appliance(deadline_datetime=deadline, deadline_policy="STRICT")
|
||||
zero = to_datetime("2026-09-16T08:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=16
|
||||
) == [0, 1, 2]
|
||||
assert not device.deadline_missed([2], zero)
|
||||
assert device.deadline_missed([3], zero)
|
||||
@@ -0,0 +1,385 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
ems_eos = get_ems(init=True) # init once
|
||||
|
||||
DIR_TESTDATA = Path(__file__).parent / "testdata"
|
||||
|
||||
|
||||
def test_direct_marketing_preserves_constant_supplied_feed_in_tariff(config_eos: ConfigEOS):
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0, 0.0],
|
||||
"strompreis_euro_pro_wh": [0.0002, -0.0001],
|
||||
"einspeiseverguetung_euro_pro_wh": [0.00007, 0.00007],
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [0.0, 0.0],
|
||||
},
|
||||
pv_battery=None,
|
||||
# Without an inverter the simulation books no grid energy at all, so the
|
||||
# price signal would never reach the fitness.
|
||||
inverter={"device_id": "inverter1", "max_power_wh": 20000},
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
|
||||
adjusted = GeneticOptimization()._parameters_for_config(parameters)
|
||||
|
||||
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.00007, 0.00007]
|
||||
assert parameters.ems.einspeiseverguetung_euro_pro_wh == [0.00007, 0.00007]
|
||||
|
||||
|
||||
def test_direct_marketing_keeps_variable_feed_in_tariff(config_eos: ConfigEOS):
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0, 0.0],
|
||||
"strompreis_euro_pro_wh": [0.0002, 0.0003],
|
||||
"einspeiseverguetung_euro_pro_wh": [0.0001, -0.00005],
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [0.0, 0.0],
|
||||
},
|
||||
pv_battery=None,
|
||||
inverter=None,
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
|
||||
adjusted = GeneticOptimization()._parameters_for_config(parameters)
|
||||
|
||||
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0001, -0.00005]
|
||||
|
||||
|
||||
def test_grid_export_rates_reach_the_solution(config_eos: ConfigEOS):
|
||||
"""Configured export rates end up as per-slot export levels in the solution."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 24},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"individuals": 40,
|
||||
"generations": 10,
|
||||
"tail_horizon_hours": 0,
|
||||
"horizon_hours": 24,
|
||||
"interval_sec": 3600,
|
||||
}
|
||||
},
|
||||
"feedintariff": {"direct_marketing_enabled": True},
|
||||
"devices": {
|
||||
"max_batteries": 1,
|
||||
"batteries": {
|
||||
"battery1": {"device_id": "battery1", "grid_export_rates": [0.5, 1.0]}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
CacheEnergyManagementStore().clear()
|
||||
|
||||
hours = 24
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
"strompreis_euro_pro_wh": [0.0003] * hours,
|
||||
# A pronounced tariff peak makes exporting worthwhile at all.
|
||||
"einspeiseverguetung_euro_pro_wh": [0.0001] * 12 + [0.0009] * 12,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [200.0] * hours,
|
||||
},
|
||||
pv_battery={
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 10000,
|
||||
"initial_soc_percentage": 100,
|
||||
"min_soc_percentage": 0,
|
||||
"max_charge_power_w": 5000,
|
||||
},
|
||||
inverter={
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 10000,
|
||||
"battery_id": "battery1",
|
||||
},
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
|
||||
optimization = GeneticOptimization(fixed_seed=42)
|
||||
solution = optimization.optimize_ems(parameters=parameters, start_hour=0, ngen=3)
|
||||
|
||||
# Full power first, so the full-power state keeps the lowest export index.
|
||||
assert optimization.bat_possible_grid_export_values == [1.0, 0.5]
|
||||
assert len(solution.battery_grid_export_factor) == len(solution.battery_grid_export_allowed)
|
||||
assert set(solution.battery_grid_export_factor) <= {0.0, 0.5, 1.0}
|
||||
assert [
|
||||
1 if factor > 0.0 else 0 for factor in solution.battery_grid_export_factor
|
||||
] == solution.battery_grid_export_allowed
|
||||
|
||||
# @TODO
|
||||
|
||||
|
||||
def _ev_deadline_parameters(hours: int, **ev_extra) -> GeneticOptimizationParameters:
|
||||
"""Optimization parameters with an EV that has to be charged."""
|
||||
return GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
# Expensive for the first six hours, dirt cheap afterwards: without a
|
||||
# deadline the optimizer would always wait for the cheap slots.
|
||||
"strompreis_euro_pro_wh": [0.0009] * 6 + [0.00001] * (hours - 6),
|
||||
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [300.0] * hours,
|
||||
},
|
||||
pv_battery=None,
|
||||
inverter=None,
|
||||
ev={
|
||||
"device_id": "ev1",
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.95,
|
||||
"max_charge_power_w": 11040,
|
||||
"initial_soc_percentage": 20,
|
||||
"min_soc_percentage": 60,
|
||||
**ev_extra,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_ev_deadline_slot_resolution(config_eos: ConfigEOS):
|
||||
"""Datetime and maximum duration resolve to a slot; the earlier one wins."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval_sec": 3600}
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
|
||||
optimization = GeneticOptimization(fixed_seed=1)
|
||||
optimization._slot0_datetime = optimization.ems.start_datetime
|
||||
slot0 = optimization._slot0_datetime
|
||||
|
||||
# Duration only: 6 h after the start hour 10.
|
||||
parameters = _ev_deadline_parameters(48, min_soc_max_duration_h=6)
|
||||
assert optimization._ev_deadline_slot(parameters) == 6
|
||||
|
||||
# Datetime only.
|
||||
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=14))
|
||||
assert optimization._ev_deadline_slot(parameters) == 14
|
||||
|
||||
# Both: the earlier one wins.
|
||||
parameters = _ev_deadline_parameters(
|
||||
48, min_soc_deadline_datetime=slot0.add(hours=20), min_soc_max_duration_h=6
|
||||
)
|
||||
assert optimization._ev_deadline_slot(parameters) == 6
|
||||
|
||||
# Beyond the horizon: no deadline, the end-of-horizon target already covers it.
|
||||
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=100))
|
||||
assert optimization._ev_deadline_slot(parameters) is None
|
||||
|
||||
# In the past: due right now.
|
||||
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.subtract(hours=2))
|
||||
assert optimization._ev_deadline_slot(parameters) == 0
|
||||
|
||||
# No deadline at all.
|
||||
assert optimization._ev_deadline_slot(_ev_deadline_parameters(48)) is None
|
||||
|
||||
|
||||
def test_ev_soc_penalty_reads_the_deadline_slot(config_eos: ConfigEOS):
|
||||
"""With a deadline the penalty checks the SoC at that slot, not at the end."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval_sec": 3600}
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
|
||||
optimization = GeneticOptimization(fixed_seed=1)
|
||||
simulation_result = {"EAuto_SoC_pro_Stunde": [20.0, 35.0, 50.0, 80.0]}
|
||||
|
||||
optimization.simulation.ev = MagicMock(spec=Battery)
|
||||
optimization.simulation.ev.current_soc_percentage.return_value = 80.0
|
||||
|
||||
# Without a deadline the final SoC counts.
|
||||
optimization._ev_soc_deadline_slot = None
|
||||
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
|
||||
|
||||
# With one, the SoC at the beginning of the deadline slot counts.
|
||||
optimization._ev_soc_deadline_slot = 12
|
||||
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 50.0
|
||||
|
||||
# A deadline beyond the reported slots falls back to the final SoC.
|
||||
optimization._ev_soc_deadline_slot = 99
|
||||
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
|
||||
|
||||
|
||||
def test_ev_deadline_charges_before_departure(config_eos: ConfigEOS):
|
||||
"""The EV reaches its target before the deadline even when energy is cheaper later."""
|
||||
hours = 24
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": hours},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"individuals": 100,
|
||||
"generations": 40,
|
||||
"tail_horizon_hours": 0,
|
||||
"horizon_hours": hours,
|
||||
"interval_sec": 3600,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
CacheEnergyManagementStore().clear()
|
||||
|
||||
parameters = _ev_deadline_parameters(hours, min_soc_max_duration_h=6)
|
||||
solution = GeneticOptimization(fixed_seed=42).optimize_ems(
|
||||
parameters=parameters, start_hour=0, ngen=40
|
||||
)
|
||||
|
||||
soc_per_hour = solution.result.ev_soc_per_hour
|
||||
# Slot 6 is the first slot at or after the deadline, so its start-of-slot SoC
|
||||
# is what the target is checked against.
|
||||
assert soc_per_hour[6] >= 60.0
|
||||
|
||||
|
||||
def _terminal_value_run(
|
||||
config_eos: ConfigEOS, mode: str, prices: Optional[list[float]] = None
|
||||
) -> GeneticSolution:
|
||||
"""48 h with expensive energy and two dirt-cheap slots at the very end.
|
||||
|
||||
Charging in those last slots only pays off when the stored energy keeps a
|
||||
value beyond the horizon.
|
||||
"""
|
||||
hours = 48
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": hours},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"individuals": 80,
|
||||
"generations": 20,
|
||||
"tail_horizon_hours": 0,
|
||||
"horizon_hours": hours,
|
||||
"interval_sec": 3600,
|
||||
"terminal_value_mode": mode,
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
CacheEnergyManagementStore().clear()
|
||||
|
||||
if prices is None:
|
||||
prices = [0.0004] * (hours - 2) + [0.00002] * 2
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
"strompreis_euro_pro_wh": prices,
|
||||
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [200.0] * hours,
|
||||
},
|
||||
pv_battery={
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 10000,
|
||||
"initial_soc_percentage": 20,
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100,
|
||||
"charging_efficiency": 1.0,
|
||||
"discharging_efficiency": 1.0,
|
||||
"max_charge_power_w": 5000,
|
||||
},
|
||||
inverter={
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 10000,
|
||||
"battery_id": "battery1",
|
||||
"ac_to_dc_efficiency": 1.0,
|
||||
"dc_to_ac_efficiency": 1.0,
|
||||
"max_ac_charge_power_w": 5000,
|
||||
},
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
return GeneticOptimization(fixed_seed=7).optimize_ems(
|
||||
parameters=parameters, start_hour=0, ngen=20
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_value_auto_keeps_energy_that_fixed_zero_throws_away(config_eos: ConfigEOS):
|
||||
"""AUTO values the energy left in the battery, a fixed zero does not."""
|
||||
auto = _terminal_value_run(config_eos, "AUTO")
|
||||
fixed = _terminal_value_run(config_eos, "FIXED")
|
||||
|
||||
assert auto.terminal_value is not None
|
||||
assert auto.terminal_value.mode == "AUTO"
|
||||
assert auto.terminal_value.curve is not None
|
||||
assert auto.terminal_value.credited_euro > 0.0
|
||||
|
||||
assert fixed.terminal_value is not None
|
||||
assert fixed.terminal_value.mode == "FIXED"
|
||||
assert fixed.terminal_value.credited_euro == 0.0
|
||||
|
||||
# The cheap slots at the end are only worth using with a terminal value.
|
||||
assert auto.result.battery_soc_per_hour[-1] > fixed.result.battery_soc_per_hour[-1]
|
||||
|
||||
|
||||
def test_terminal_value_curve_is_concave_and_reported(config_eos: ConfigEOS):
|
||||
"""The reported curve is what the credit was read from."""
|
||||
solution = _terminal_value_run(config_eos, "AUTO")
|
||||
assert solution.terminal_value is not None
|
||||
curve = solution.terminal_value.curve
|
||||
assert curve is not None
|
||||
|
||||
assert curve.window_slots == 24
|
||||
assert len(curve.energy_wh) == len(curve.value_euro)
|
||||
assert len(curve.marginal_euro_per_kwh) == len(curve.energy_wh) - 1
|
||||
marginals = curve.marginal_euro_per_kwh
|
||||
assert all(a >= b for a, b in zip(marginals, marginals[1:]))
|
||||
|
||||
# The credit is the curve evaluated at the energy left in the battery.
|
||||
expected = curve.value(solution.terminal_value.battery_energy_wh)
|
||||
assert solution.terminal_value.credited_euro == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_terminal_value_reports_why_it_fell_back_to_fixed(config_eos: ConfigEOS):
|
||||
"""AUTO without any prices cannot build a curve - and has to say so.
|
||||
|
||||
A request whose price forecast is all zeros used to be indistinguishable
|
||||
from a run configured for FIXED.
|
||||
"""
|
||||
hours = 48
|
||||
solution = _terminal_value_run(config_eos, "AUTO", prices=[0.0] * hours)
|
||||
|
||||
assert solution.terminal_value is not None
|
||||
assert solution.terminal_value.mode == "FIXED"
|
||||
assert solution.terminal_value.curve is None
|
||||
assert solution.terminal_value.reason is not None
|
||||
assert "no priced residual load" in solution.terminal_value.reason
|
||||
|
||||
configured = _terminal_value_run(config_eos, "FIXED")
|
||||
assert configured.terminal_value is not None
|
||||
assert configured.terminal_value.reason == "terminal_value_mode is FIXED"
|
||||
@@ -0,0 +1,528 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.devices.genetic.battery import (
|
||||
Battery,
|
||||
ElectricVehicleParameters,
|
||||
SolarPanelBatteryParameters,
|
||||
)
|
||||
from akkudoktoreos.devices.genetic.homeappliance import (
|
||||
HomeAppliance,
|
||||
HomeApplianceParameters,
|
||||
)
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
)
|
||||
|
||||
start_hour = 1
|
||||
|
||||
|
||||
# Example initialization of necessary components
|
||||
@pytest.fixture
|
||||
def genetic_simulation(config_eos) -> GeneticSimulation:
|
||||
"""Fixture to create an EnergyManagement instance with given test parameters."""
|
||||
# Assure configuration holds the correct values
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {"hours": 24, "genetic": {"tail_horizon_hours": 0}},
|
||||
}
|
||||
)
|
||||
assert config_eos.prediction.hours == 48
|
||||
assert config_eos.optimization.genetic.horizon_hours == 24
|
||||
|
||||
# Initialize the battery and the inverter
|
||||
akku = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=5000,
|
||||
initial_soc_percentage=80,
|
||||
min_soc_percentage=10,
|
||||
),
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
akku.reset()
|
||||
|
||||
inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1", max_power_wh=10000, battery_id=akku.parameters.device_id
|
||||
),
|
||||
battery=akku,
|
||||
)
|
||||
|
||||
# Flexible consumer (fixed start at slot 2 for this deterministic test)
|
||||
home_appliance = HomeAppliance(
|
||||
HomeApplianceParameters(
|
||||
device_id="dishwasher1",
|
||||
consumption_wh=2000,
|
||||
duration_h=2,
|
||||
time_windows=None,
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
home_appliance.build_load_curve([2])
|
||||
|
||||
# Example initialization of electric car battery
|
||||
eauto = Battery(
|
||||
ElectricVehicleParameters(
|
||||
device_id="ev1", capacity_wh=26400, initial_soc_percentage=10, min_soc_percentage=10
|
||||
),
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
eauto.set_charge_per_hour(np.full(config_eos.prediction.hours, 1))
|
||||
|
||||
# Parameters based on previous example data
|
||||
pv_prognose_wh = [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
8.05,
|
||||
352.91,
|
||||
728.51,
|
||||
930.28,
|
||||
1043.25,
|
||||
1106.74,
|
||||
1161.69,
|
||||
6018.82,
|
||||
5519.07,
|
||||
3969.88,
|
||||
3017.96,
|
||||
1943.07,
|
||||
1007.17,
|
||||
319.67,
|
||||
7.88,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5.04,
|
||||
335.59,
|
||||
705.32,
|
||||
1121.12,
|
||||
1604.79,
|
||||
2157.38,
|
||||
1433.25,
|
||||
5718.49,
|
||||
4553.96,
|
||||
3027.55,
|
||||
2574.46,
|
||||
1720.4,
|
||||
963.4,
|
||||
383.3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]
|
||||
|
||||
strompreis_euro_pro_wh = [
|
||||
0.0003384,
|
||||
0.0003318,
|
||||
0.0003284,
|
||||
0.0003283,
|
||||
0.0003289,
|
||||
0.0003334,
|
||||
0.0003290,
|
||||
0.0003302,
|
||||
0.0003042,
|
||||
0.0002430,
|
||||
0.0002280,
|
||||
0.0002212,
|
||||
0.0002093,
|
||||
0.0001879,
|
||||
0.0001838,
|
||||
0.0002004,
|
||||
0.0002198,
|
||||
0.0002270,
|
||||
0.0002997,
|
||||
0.0003195,
|
||||
0.0003081,
|
||||
0.0002969,
|
||||
0.0002921,
|
||||
0.0002780,
|
||||
0.0003384,
|
||||
0.0003318,
|
||||
0.0003284,
|
||||
0.0003283,
|
||||
0.0003289,
|
||||
0.0003334,
|
||||
0.0003290,
|
||||
0.0003302,
|
||||
0.0003042,
|
||||
0.0002430,
|
||||
0.0002280,
|
||||
0.0002212,
|
||||
0.0002093,
|
||||
0.0001879,
|
||||
0.0001838,
|
||||
0.0002004,
|
||||
0.0002198,
|
||||
0.0002270,
|
||||
0.0002997,
|
||||
0.0003195,
|
||||
0.0003081,
|
||||
0.0002969,
|
||||
0.0002921,
|
||||
0.0002780,
|
||||
]
|
||||
|
||||
einspeiseverguetung_euro_pro_wh = 0.00007
|
||||
preis_euro_pro_wh_akku = 0.0001
|
||||
|
||||
gesamtlast = [
|
||||
676.71,
|
||||
876.19,
|
||||
527.13,
|
||||
468.88,
|
||||
531.38,
|
||||
517.95,
|
||||
483.15,
|
||||
472.28,
|
||||
1011.68,
|
||||
995.00,
|
||||
1053.07,
|
||||
1063.91,
|
||||
1320.56,
|
||||
1132.03,
|
||||
1163.67,
|
||||
1176.82,
|
||||
1216.22,
|
||||
1103.78,
|
||||
1129.12,
|
||||
1178.71,
|
||||
1050.98,
|
||||
988.56,
|
||||
912.38,
|
||||
704.61,
|
||||
516.37,
|
||||
868.05,
|
||||
694.34,
|
||||
608.79,
|
||||
556.31,
|
||||
488.89,
|
||||
506.91,
|
||||
804.89,
|
||||
1141.98,
|
||||
1056.97,
|
||||
992.46,
|
||||
1155.99,
|
||||
827.01,
|
||||
1257.98,
|
||||
1232.67,
|
||||
871.26,
|
||||
860.88,
|
||||
1158.03,
|
||||
1222.72,
|
||||
1221.04,
|
||||
949.99,
|
||||
987.01,
|
||||
733.99,
|
||||
592.97,
|
||||
]
|
||||
|
||||
# Initialize the energy management system with the respective parameters
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.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.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
ev=eauto,
|
||||
home_appliances=[home_appliance],
|
||||
)
|
||||
|
||||
# Init for test
|
||||
assert simulation.ac_charge_hours is not None
|
||||
assert simulation.dc_charge_hours is not None
|
||||
assert simulation.bat_discharge_hours is not None
|
||||
assert simulation.bat_grid_export_hours is not None
|
||||
assert simulation.ev_charge_hours is not None
|
||||
simulation.ac_charge_hours[start_hour] = 1.0
|
||||
simulation.dc_charge_hours[start_hour] = 1.0
|
||||
simulation.bat_discharge_hours[start_hour] = 1.0
|
||||
simulation.ev_charge_hours[start_hour] = 1.0
|
||||
|
||||
return simulation
|
||||
|
||||
|
||||
def test_ev_charging_uses_raw_input_energy_for_load_and_grid(config_eos):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 1},
|
||||
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": 1}},
|
||||
}
|
||||
)
|
||||
ev = Battery(
|
||||
ElectricVehicleParameters(
|
||||
device_id="ev1",
|
||||
capacity_wh=1000,
|
||||
charging_efficiency=0.8,
|
||||
max_charge_power_w=100,
|
||||
initial_soc_percentage=0,
|
||||
min_soc_percentage=0,
|
||||
),
|
||||
prediction_hours=1,
|
||||
)
|
||||
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=1000.0))
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[0.0],
|
||||
strompreis_euro_pro_wh=[0.001],
|
||||
einspeiseverguetung_euro_pro_wh=[0.0],
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0],
|
||||
)
|
||||
),
|
||||
optimization_hours=1,
|
||||
prediction_hours=1,
|
||||
inverter=inverter,
|
||||
ev=ev,
|
||||
)
|
||||
simulation.ev_charge_hours = np.array([1.0])
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Last_Wh_pro_Stunde"][0] == pytest.approx(100.0)
|
||||
assert result["Netzbezug_Wh_pro_Stunde"][0] == pytest.approx(100.0)
|
||||
assert result["Kosten_Euro_pro_Stunde"][0] == pytest.approx(0.1)
|
||||
assert result["Verluste_Pro_Stunde"][0] == pytest.approx(20.0)
|
||||
assert ev.current_soc_percentage() == pytest.approx(8.0)
|
||||
|
||||
|
||||
def test_direct_marketing_curtails_negative_feed_in(config_eos, monkeypatch):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 2},
|
||||
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": 2}},
|
||||
}
|
||||
)
|
||||
|
||||
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=1000.0))
|
||||
monkeypatch.setattr(
|
||||
inverter.self_consumption_predictor,
|
||||
"calculate_expected_direct_consumption",
|
||||
Mock(side_effect=min),
|
||||
)
|
||||
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[500.0, 500.0],
|
||||
strompreis_euro_pro_wh=[-0.0001, -0.0001],
|
||||
einspeiseverguetung_euro_pro_wh=[-0.0001, -0.0001],
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0, 0.0],
|
||||
)
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
direct_marketing_enabled=True,
|
||||
)
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == 0.0
|
||||
assert result["Einnahmen_Euro_pro_Stunde"][0] == 0.0
|
||||
assert result["Verluste_Pro_Stunde"][0] == pytest.approx(500.0)
|
||||
|
||||
|
||||
def _direct_marketing_battery_export_simulation(
|
||||
config_eos,
|
||||
levelized_cost_of_storage_kwh: float = 0.0,
|
||||
dc_to_ac_efficiency: float = 1.0,
|
||||
) -> GeneticSimulation:
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 2},
|
||||
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": 2}},
|
||||
}
|
||||
)
|
||||
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=1000,
|
||||
initial_soc_percentage=100,
|
||||
min_soc_percentage=0,
|
||||
charging_efficiency=1.0,
|
||||
discharging_efficiency=1.0,
|
||||
levelized_cost_of_storage_kwh=levelized_cost_of_storage_kwh,
|
||||
max_charge_power_w=500,
|
||||
),
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
max_power_wh=500.0,
|
||||
battery_id=battery.parameters.device_id,
|
||||
dc_to_ac_efficiency=dc_to_ac_efficiency,
|
||||
),
|
||||
battery=battery,
|
||||
)
|
||||
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[0.0, 0.0],
|
||||
strompreis_euro_pro_wh=[0.0, 0.0],
|
||||
einspeiseverguetung_euro_pro_wh=[0.0002, 0.0002],
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0, 0.0],
|
||||
)
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
direct_marketing_enabled=True,
|
||||
)
|
||||
return simulation
|
||||
|
||||
|
||||
def test_direct_marketing_discharge_allowed_does_not_export_battery(config_eos):
|
||||
simulation = _direct_marketing_battery_export_simulation(config_eos)
|
||||
assert simulation.bat_discharge_hours is not None
|
||||
simulation.bat_discharge_hours[0] = 1
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == 0.0
|
||||
assert simulation.battery is not None
|
||||
assert simulation.battery.current_soc_percentage() == 100.0
|
||||
|
||||
|
||||
def test_direct_marketing_battery_grid_export_uses_separate_signal(config_eos):
|
||||
simulation = _direct_marketing_battery_export_simulation(config_eos)
|
||||
assert simulation.bat_grid_export_hours is not None
|
||||
simulation.bat_grid_export_hours[0] = 1
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == pytest.approx(500.0)
|
||||
assert result["Einnahmen_Euro_pro_Stunde"][0] == pytest.approx(0.1)
|
||||
assert simulation.battery is not None
|
||||
assert simulation.battery.current_soc_percentage() == 50.0
|
||||
|
||||
|
||||
def test_direct_marketing_grid_export_rate_limits_exported_energy(config_eos):
|
||||
"""A partial export level exports that share of the rated discharge power."""
|
||||
simulation = _direct_marketing_battery_export_simulation(config_eos)
|
||||
assert simulation.bat_grid_export_hours is not None
|
||||
# 500 W rated discharge power over a one hour slot -> 500 Wh at rate 1.0.
|
||||
simulation.bat_grid_export_hours[0] = 0.5
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == pytest.approx(250.0)
|
||||
assert simulation.battery is not None
|
||||
assert simulation.battery.current_soc_percentage() == 75.0
|
||||
|
||||
|
||||
def test_battery_lcos_is_charged_once_on_delivered_energy(config_eos):
|
||||
simulation = _direct_marketing_battery_export_simulation(
|
||||
config_eos,
|
||||
levelized_cost_of_storage_kwh=0.12,
|
||||
dc_to_ac_efficiency=0.8,
|
||||
)
|
||||
assert simulation.bat_grid_export_hours is not None
|
||||
simulation.bat_grid_export_hours[0] = 1
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
# The battery delivers 500 Wh DC, so LCOS is 0.5 kWh * 0.12 EUR/kWh
|
||||
# = 0.06 EUR exactly once. After the 80% inverter, 400 Wh AC reaches
|
||||
# the grid and earns 400 Wh * 0.0002 EUR/Wh = 0.08 EUR.
|
||||
assert result["Kosten_Euro_pro_Stunde"][0] == pytest.approx(0.06)
|
||||
assert result["Gesamtkosten_Euro"] == pytest.approx(0.06)
|
||||
assert result["Einnahmen_Euro_pro_Stunde"][0] == pytest.approx(0.08)
|
||||
assert result["Gesamtbilanz_Euro"] == pytest.approx(-0.02)
|
||||
|
||||
|
||||
def test_disabled_ac_charging_clears_the_reported_plan(config_eos):
|
||||
"""With AC charging off the reported plan must not keep charge commands.
|
||||
|
||||
The simulation ignores the AC charge genes when the inverter forbids grid
|
||||
charging. The solution is read back from the same array, so a controller
|
||||
acting on it would grid-charge the battery although no such charge was ever
|
||||
simulated or paid for.
|
||||
"""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 2},
|
||||
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": 2}},
|
||||
}
|
||||
)
|
||||
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=10000,
|
||||
initial_soc_percentage=50,
|
||||
min_soc_percentage=0,
|
||||
charging_efficiency=1.0,
|
||||
discharging_efficiency=1.0,
|
||||
max_charge_power_w=5000,
|
||||
),
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
max_power_wh=5000.0,
|
||||
battery_id=battery.parameters.device_id,
|
||||
max_ac_charge_power_w=0, # Netzladen deaktiviert
|
||||
),
|
||||
battery=battery,
|
||||
)
|
||||
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[0.0, 0.0],
|
||||
strompreis_euro_pro_wh=[0.0003, 0.0003],
|
||||
einspeiseverguetung_euro_pro_wh=[0.0001, 0.0001],
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0, 0.0],
|
||||
)
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
)
|
||||
simulation.ac_charge_hours = np.array([0.8, 0.0])
|
||||
|
||||
soc_before = battery.current_soc_percentage()
|
||||
simulation.simulate(start_hour=0)
|
||||
|
||||
# Nothing was charged ...
|
||||
assert battery.current_soc_percentage() == pytest.approx(soc_before)
|
||||
# ... and the plan says so.
|
||||
assert simulation.ac_charge_hours is not None
|
||||
assert list(simulation.ac_charge_hours) == [0.0, 0.0]
|
||||
@@ -0,0 +1,188 @@
|
||||
# ruff: noqa: S101
|
||||
|
||||
import numpy as np
|
||||
|
||||
from akkudoktoreos.devices.devicesabc import BatteryOperationMode
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
|
||||
|
||||
def test_battery_discharge_allowed_remains_local_load_mode(config_eos):
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
solution = GeneticSolution
|
||||
|
||||
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
|
||||
ac_charge=0.0,
|
||||
dc_charge=0.0,
|
||||
discharge_allowed=True,
|
||||
)
|
||||
|
||||
assert operation_mode == BatteryOperationMode.PEAK_SHAVING
|
||||
assert operation_mode_factor == 1.0
|
||||
|
||||
|
||||
def test_battery_grid_export_signal_maps_to_grid_support_export(config_eos):
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
solution = GeneticSolution
|
||||
|
||||
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
|
||||
ac_charge=0.0,
|
||||
dc_charge=0.0,
|
||||
discharge_allowed=False,
|
||||
battery_grid_export_allowed=True,
|
||||
)
|
||||
|
||||
assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT
|
||||
assert operation_mode_factor == 1.0
|
||||
|
||||
|
||||
def test_decode_charge_discharge_has_separate_battery_grid_export_state():
|
||||
optimization = GeneticOptimization()
|
||||
optimization.bat_possible_charge_values = [1.0]
|
||||
optimization.optimize_dc_charge = True
|
||||
optimization.optimize_battery_grid_export = True
|
||||
|
||||
ac_charge, dc_charge, discharge, battery_grid_export = optimization.decode_charge_discharge(
|
||||
np.array([5])
|
||||
)
|
||||
|
||||
assert ac_charge.tolist() == [0.0]
|
||||
assert dc_charge.tolist() == [0]
|
||||
assert discharge.tolist() == [0]
|
||||
assert battery_grid_export.tolist() == [1]
|
||||
|
||||
|
||||
def test_decode_charge_discharge_has_self_consumption_state_after_legacy_export():
|
||||
optimization = GeneticOptimization()
|
||||
optimization.bat_possible_charge_values = [1.0]
|
||||
optimization.optimize_dc_charge = True
|
||||
optimization.optimize_battery_grid_export = True
|
||||
|
||||
layout = optimization._battery_state_layout()
|
||||
ac_charge, dc_charge, discharge, battery_grid_export = optimization.decode_charge_discharge(
|
||||
np.array([6])
|
||||
)
|
||||
|
||||
assert layout.total_states == 7
|
||||
assert layout.grid_export_state == 5
|
||||
assert layout.self_consumption_state == 6
|
||||
assert ac_charge.tolist() == [0.0]
|
||||
assert dc_charge.tolist() == [1]
|
||||
assert discharge.tolist() == [1]
|
||||
assert battery_grid_export.tolist() == [0]
|
||||
|
||||
|
||||
def test_graded_grid_export_states_decode_to_rates():
|
||||
"""Each configured export rate gets its own state; state 5 stays full power."""
|
||||
optimization = GeneticOptimization()
|
||||
optimization.bat_possible_charge_values = [1.0]
|
||||
optimization.bat_possible_grid_export_values = [1.0, 0.5, 0.25]
|
||||
optimization.optimize_dc_charge = True
|
||||
optimization.optimize_battery_grid_export = True
|
||||
|
||||
layout = optimization._battery_state_layout()
|
||||
|
||||
assert layout.grid_export_states == (5, 6, 7)
|
||||
# The full-power state keeps its index, so existing seeds stay valid.
|
||||
assert layout.grid_export_state == 5
|
||||
assert layout.self_consumption_state == 8
|
||||
assert layout.total_states == 9
|
||||
|
||||
_, _, _, battery_grid_export = optimization.decode_charge_discharge(np.array([0, 5, 6, 7]))
|
||||
assert battery_grid_export.tolist() == [0.0, 1.0, 0.5, 0.25]
|
||||
|
||||
|
||||
def test_single_export_rate_keeps_all_or_nothing_layout():
|
||||
"""Without configured rates the state space is the one from before grading."""
|
||||
optimization = GeneticOptimization()
|
||||
optimization.bat_possible_charge_values = [1.0]
|
||||
optimization.optimize_dc_charge = True
|
||||
optimization.optimize_battery_grid_export = True
|
||||
|
||||
layout = optimization._battery_state_layout()
|
||||
|
||||
assert layout.grid_export_states == (5,)
|
||||
assert layout.total_states == 7
|
||||
|
||||
|
||||
def test_battery_grid_export_factor_becomes_operation_factor(config_eos):
|
||||
"""A partial export level is reported as the GRID_SUPPORT_EXPORT factor."""
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
solution = GeneticSolution
|
||||
|
||||
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
|
||||
ac_charge=0.0,
|
||||
dc_charge=0.0,
|
||||
discharge_allowed=False,
|
||||
battery_grid_export_allowed=True,
|
||||
battery_grid_export_factor=0.25,
|
||||
)
|
||||
|
||||
assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT
|
||||
assert operation_mode_factor == 0.25
|
||||
|
||||
|
||||
def test_disjoint_cycle_masks_keep_feasible_non_deadline_order(config_eos):
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"interval_sec": 900,
|
||||
"horizon_hours": 2,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
get_ems(init=True).set_start_datetime(
|
||||
to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
)
|
||||
params = GeneticOptimizationParameters.model_validate(
|
||||
{
|
||||
"ems": {
|
||||
"pv_forecast_wh": [0.0] * 8,
|
||||
"total_load": [0.0] * 8,
|
||||
"electricity_price_per_wh": [0.0003] * 8,
|
||||
"feed_in_tariff_per_wh": 0.0,
|
||||
"price_per_wh_battery": 0.0,
|
||||
},
|
||||
"forecast_interval_seconds": 900,
|
||||
"pv_battery": None,
|
||||
"ev": None,
|
||||
"inverter": {"device_id": "inv", "max_power_wh": 1000},
|
||||
"home_appliances": [
|
||||
{
|
||||
"device_id": "washer",
|
||||
"num_cycles": 2,
|
||||
"load_profile_power_w": [1000.0, 1000.0],
|
||||
"load_profile_interval_seconds": 900,
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "00:00", "duration": "30 minutes", "value": 1},
|
||||
{"start_time": "01:15", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
optimizer = GeneticOptimization(fixed_seed=42)
|
||||
solution = optimizer.optimize_ems(params, ngen=1, individuals=6)
|
||||
assert [(item.hour, item.minute) for item in solution.appliance_starts["washer"]] == [
|
||||
(0, 0),
|
||||
(1, 0),
|
||||
]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == 1000.0
|
||||
# Even a candidate choosing the second disconnected window repairs to the
|
||||
# feasible order, without dropping a configured run or crossing its mask.
|
||||
genes = [0, 1]
|
||||
assert optimizer._decode_appliance_starts(genes) == {0: [0, 4]}
|
||||
assert genes == [0, 0]
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Economic tail scenarios and hard control/forecast boundaries."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import SettingsEOSDefaults
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.tailvalue import build_tail_value_curve
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueCurve
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
def devices(power=1000, efficiency=1.0, lcos=0, ac_limit=None, export_power=5000):
|
||||
bat = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=1000,
|
||||
max_charge_power_w=power,
|
||||
charging_efficiency=efficiency,
|
||||
discharging_efficiency=efficiency,
|
||||
initial_soc_percentage=50,
|
||||
levelized_cost_of_storage_kwh=lcos,
|
||||
charge_rates=[0, 0.5, 1],
|
||||
),
|
||||
prediction_hours=1,
|
||||
)
|
||||
inv = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
battery_id="battery1",
|
||||
max_power_wh=export_power,
|
||||
dc_to_ac_efficiency=1,
|
||||
ac_to_dc_efficiency=1,
|
||||
max_ac_charge_power_w=ac_limit,
|
||||
),
|
||||
battery=bat,
|
||||
)
|
||||
return bat, inv
|
||||
|
||||
|
||||
def curve(
|
||||
prices=(-0.1, 0.3),
|
||||
tariffs=(0, 0.3),
|
||||
direct=True,
|
||||
continuation=None,
|
||||
load=None,
|
||||
pv=None,
|
||||
**kwargs,
|
||||
):
|
||||
bat, inv = devices(**kwargs)
|
||||
return build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array(prices) / 1000,
|
||||
feed_in_euro_per_wh=np.array(tariffs) / 1000,
|
||||
load_wh=np.zeros(len(prices)) if load is None else np.array(load),
|
||||
pv_wh=np.zeros(len(prices)) if pv is None else np.array(pv),
|
||||
continuation=continuation or TerminalValueCurve(),
|
||||
charge_rates=[0.5, 1],
|
||||
export_rates=[1],
|
||||
direct_marketing=direct,
|
||||
)
|
||||
|
||||
|
||||
def test_headroom_has_value_and_empty_state_can_earn():
|
||||
c = curve()
|
||||
assert c.value(0) == pytest.approx(0.4)
|
||||
tail, continuation = c.component_values(0)
|
||||
assert tail == pytest.approx(0.4)
|
||||
assert continuation == pytest.approx(0.0)
|
||||
assert c.value(0) == pytest.approx(tail + continuation)
|
||||
assert c.value(500) > c.value(1000)
|
||||
assert any(v < 0 for v in c.marginal_euro_per_kwh)
|
||||
|
||||
|
||||
def test_chronology_changes_arbitrage():
|
||||
forward = curve()
|
||||
reverse = curve(prices=(0.3, -0.1), tariffs=(0.3, 0))
|
||||
assert forward.value(0) > reverse.value(0)
|
||||
|
||||
|
||||
def test_discharge_and_ac_power_limits():
|
||||
limited = curve(prices=(1,), tariffs=(1,), power=100)
|
||||
assert limited.value(1000) == pytest.approx(0.1)
|
||||
limited_ac = curve(ac_limit=100)
|
||||
assert limited_ac.value(0) == pytest.approx(0.04)
|
||||
limited_inverter = curve(prices=(1,), tariffs=(1,), export_power=50)
|
||||
assert limited_inverter.value(1000) == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_losses_and_lcos_reduce_arbitrage():
|
||||
ideal = curve(prices=(0.1, 0.3))
|
||||
lossy = curve(prices=(0.1, 0.3), efficiency=0.8)
|
||||
assert 0 < lossy.value(0) < ideal.value(0)
|
||||
assert curve(prices=(0.1, 0.3), lcos=0.25).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_no_battery_export_without_permission():
|
||||
assert curve(prices=(0.1, 0.3), direct=False).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_pv_surplus_can_be_stored_for_local_load():
|
||||
c = curve(prices=(0.2, 0.3), tariffs=(0, 0), pv=[1000, 0], load=[0, 1000], direct=False)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
# Without PV the same empty battery must buy energy to serve the load.
|
||||
assert curve(prices=(0.2, 0.3), tariffs=(0, 0), load=[0, 1000], direct=False).value(
|
||||
0
|
||||
) < c.value(0)
|
||||
|
||||
|
||||
def test_continuation_survives_tail_end():
|
||||
continuation = TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.2])
|
||||
c = curve(prices=(0.5,), tariffs=(0,), continuation=continuation)
|
||||
assert c.value(1000) == pytest.approx(0.2)
|
||||
tail, continuation_credit = c.component_values(1000)
|
||||
assert tail == pytest.approx(0.0)
|
||||
assert continuation_credit == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_tail_diagnostic_plan_explains_the_selected_path():
|
||||
c = curve()
|
||||
plan = c.diagnostic_plan(0, control_horizon_hours=24)
|
||||
assert len(plan) == 2
|
||||
assert plan[0].hour_from_start == 24
|
||||
assert plan[0].action == "GRID_CHARGE"
|
||||
assert plan[0].soc_end_percentage > plan[0].soc_start_percentage
|
||||
assert plan[0].grid_import_wh > 0
|
||||
assert plan[1].action == "BATTERY_EXPORT"
|
||||
assert plan[1].soc_end_percentage < plan[1].soc_start_percentage
|
||||
assert plan[1].grid_export_wh > 0
|
||||
assert sum(slot.slot_value_euro for slot in plan) == pytest.approx(c.value(0))
|
||||
|
||||
|
||||
def test_central_config_invariant():
|
||||
# Only the control horizon is mandatory. A prediction horizon that cannot
|
||||
# cover the requested tail shortens the tail instead of failing the run,
|
||||
# so existing configurations keep starting after an upgrade.
|
||||
short = SettingsEOSDefaults.model_validate(
|
||||
dict(
|
||||
prediction={"hours": 48},
|
||||
optimization={"genetic": {"horizon_hours": 24, "tail_horizon_hours": 48}},
|
||||
)
|
||||
)
|
||||
assert short.prediction.hours == 48
|
||||
assert short.optimization.genetic.tail_horizon_hours == 48
|
||||
|
||||
# A control horizon the forecast cannot serve is not rejected here either -
|
||||
# prediction.hours also serves callers that never optimize. The optimizer
|
||||
# rejects the run itself, naming the series that ran out.
|
||||
undersized = SettingsEOSDefaults.model_validate(
|
||||
dict(
|
||||
prediction={"hours": 48},
|
||||
optimization={"genetic": {"horizon_hours": 72, "tail_horizon_hours": 0}},
|
||||
)
|
||||
)
|
||||
assert undersized.optimization.genetic.horizon_hours == 72
|
||||
|
||||
settings = SettingsEOSDefaults()
|
||||
assert settings.prediction.hours == 48
|
||||
assert settings.optimization.genetic.tail_horizon_hours == 48
|
||||
|
||||
|
||||
def setup_run(config, interval=3600, start_hour=0, hours=72, prediction_hours=72):
|
||||
config.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": prediction_hours},
|
||||
"optimization": {
|
||||
"genetic": {"horizon_hours": 24, "tail_horizon_hours": 48, "interval_sec": interval}
|
||||
},
|
||||
"feedintariff": {"direct_marketing_enabled": True},
|
||||
}
|
||||
)
|
||||
ems = get_ems(init=True)
|
||||
ems.set_start_datetime(to_datetime("2026-09-05T00:00:00").set(hour=start_hour))
|
||||
bat, inv = devices()
|
||||
params = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
"gesamtlast": [0.0] * hours,
|
||||
"strompreis_euro_pro_wh": [0.0002] * hours,
|
||||
"einspeiseverguetung_euro_pro_wh": [0.0001] * hours,
|
||||
"preis_euro_pro_wh_akku": 0,
|
||||
},
|
||||
pv_battery=bat.parameters,
|
||||
inverter=inv.parameters,
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
return GeneticOptimization(fixed_seed=42), params
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interval", [3600, 900])
|
||||
@pytest.mark.parametrize("start_hour", [0, 10])
|
||||
@pytest.mark.asyncio
|
||||
async def test_genome_output_and_final_control_state(config_eos, interval, start_hour):
|
||||
opt, params = setup_run(config_eos, interval, start_hour, hours=72 + start_hour)
|
||||
|
||||
def choose(*args, **kwargs):
|
||||
# Discharge only in the last control slot. Its POST-slot SOC is credited.
|
||||
genome = opt.create_individual()
|
||||
genome[:] = [0] * opt.control_end_slot
|
||||
genome[-1] = opt._battery_state_layout().grid_export_state
|
||||
assert len(genome) == 24 * (3600 // interval)
|
||||
return genome, {}
|
||||
|
||||
with (
|
||||
patch.object(opt, "optimize", side_effect=choose),
|
||||
patch(
|
||||
"akkudoktoreos.optimization.genetic.genetic.build_tail_value_curve",
|
||||
wraps=build_tail_value_curve,
|
||||
) as builder,
|
||||
):
|
||||
result = opt.optimize_ems(params)
|
||||
assert builder.call_count == 1
|
||||
assert len(result.ac_charge) == opt.control_slots
|
||||
assert len(result.dc_charge) == opt.control_slots
|
||||
assert len(result.discharge_allowed) == opt.control_slots
|
||||
assert len(result.battery_grid_export_factor) == opt.control_slots
|
||||
assert len(result.result.Kosten_Euro_pro_Stunde) == opt.control_slots
|
||||
assert result.terminal_value.mode == "TAIL"
|
||||
assert result.terminal_value.battery_energy_wh == pytest.approx(
|
||||
max(500 - 1000 * opt.slot_duration_h, 0)
|
||||
)
|
||||
assert result.terminal_value.effective_tail_hours == 48
|
||||
assert result.terminal_value.credited_euro == pytest.approx(
|
||||
result.terminal_value.tail_operating_euro + result.terminal_value.continuation_value_euro
|
||||
)
|
||||
assert result.terminal_value.continuation_curve is not None
|
||||
assert result.terminal_value.tail_diagnostics is not None
|
||||
assert result.terminal_value.tail_diagnostics.slots == 48 * (3600 // interval)
|
||||
assert result.terminal_value.tail_diagnostics.soc_grid_points == 101
|
||||
assert len(result.terminal_value.tail_plan) == result.terminal_value.tail_diagnostics.slots
|
||||
assert result.terminal_value.tail_plan[0].hour_from_start == 24
|
||||
assert len(result.terminal_value.curve.operating_value_euro) == 101
|
||||
assert len(result.terminal_value.curve.continuation_value_euro) == 101
|
||||
assert len((await result.optimization_solution()).solution.to_dataframe()) == opt.control_slots
|
||||
|
||||
|
||||
def test_short_tail_is_reported(config_eos, caplog):
|
||||
opt, params = setup_run(config_eos, hours=52)
|
||||
with patch.object(
|
||||
opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_end_slot, {})
|
||||
):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.effective_tail_hours == 28
|
||||
assert "Tail forecast shortened" in caplog.text
|
||||
assert result.terminal_value.reason
|
||||
|
||||
|
||||
def test_missing_control_is_rejected(config_eos):
|
||||
opt, params = setup_run(config_eos, hours=23)
|
||||
with pytest.raises(ValueError, match="Incomplete control forecast"):
|
||||
opt.optimize_ems(params)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_values_are_not_extrapolated():
|
||||
from types import SimpleNamespace
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
series = pd.Series([1.0, 2.0], index=pd.date_range(start=start, periods=2, freq="h"))
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
provider = SimpleNamespace(key_to_raw_series=AsyncMock(return_value=series))
|
||||
result = await bounded_forecast_array(
|
||||
provider,
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=3),
|
||||
interval=to_duration("15 minutes"),
|
||||
)
|
||||
assert result[:8].tolist() == [1.0] * 4 + [2.0] * 4
|
||||
assert np.isnan(result[8:]).all()
|
||||
|
||||
|
||||
def test_future_opportunity_changes_optimal_control_soc(config_eos):
|
||||
final_energy = []
|
||||
for negative_price in (0.5, -1.0):
|
||||
opt, params = setup_run(config_eos, hours=3)
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"optimization": {"genetic": {"horizon_hours": 1, "tail_horizon_hours": 2}}}
|
||||
)
|
||||
params.ems.electricity_price_per_wh = [0.0005, negative_price / 1000, 0.0003]
|
||||
params.ems.feed_in_tariff_per_wh = [0.00002, 0, 0.0003]
|
||||
result = opt.optimize_ems(params, ngen=3, individuals=20)
|
||||
final_energy.append(result.terminal_value.battery_energy_wh)
|
||||
assert final_energy[0] > final_energy[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("control", [24, 48])
|
||||
def test_continuation_prevents_emptying_at_moved_boundary(config_eos, control):
|
||||
opt, params = setup_run(config_eos, hours=96)
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 96}, "optimization": {"genetic": {"horizon_hours": control}}}
|
||||
)
|
||||
# Zero control load, with the same future local demand visible to both tails.
|
||||
params.ems.gesamtlast[60] = 1000
|
||||
params.ems.feed_in_tariff_per_wh = [0.0] * 96
|
||||
config_eos.feedintariff.direct_marketing_enabled = False
|
||||
|
||||
def choose(*a, **kw):
|
||||
from deap import creator
|
||||
|
||||
idle = creator.Individual([0] * control)
|
||||
discharge = creator.Individual([len(opt.bat_possible_charge_values)] * control)
|
||||
assert opt.toolbox.evaluate(idle)[0] <= opt.toolbox.evaluate(discharge)[0]
|
||||
return idle, {}
|
||||
|
||||
with patch.object(opt, "optimize", side_effect=choose):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.battery_energy_wh == pytest.approx(500)
|
||||
assert result.terminal_value.continuation_mode == "AUTO"
|
||||
|
||||
|
||||
def test_missing_price_inside_tail_stops_at_first_gap(config_eos):
|
||||
opt, params = setup_run(config_eos)
|
||||
params.ems.electricity_price_per_wh[30] = float("nan")
|
||||
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.effective_tail_hours == 6
|
||||
|
||||
|
||||
def test_ev_genome_and_output_are_control_only(config_eos):
|
||||
from akkudoktoreos.devices.genetic.battery import ElectricVehicleParameters
|
||||
|
||||
opt, params = setup_run(config_eos, interval=900, start_hour=10, hours=82)
|
||||
params.ev = ElectricVehicleParameters(
|
||||
device_id="ev1",
|
||||
capacity_wh=5000,
|
||||
initial_soc_percentage=0,
|
||||
min_soc_percentage=50,
|
||||
charge_rates=[0, 0.5, 1],
|
||||
)
|
||||
|
||||
def choose(*a, **kw):
|
||||
genome = opt.create_individual()
|
||||
assert len(genome) == 2 * 96
|
||||
return genome, {}
|
||||
|
||||
with patch.object(opt, "optimize", side_effect=choose):
|
||||
result = opt.optimize_ems(params)
|
||||
assert len(result.ev_charge_hours_float) == 96
|
||||
|
||||
|
||||
def test_rejected_config_update_is_atomic(config_eos):
|
||||
# The candidate is validated before the singleton is reinitialized, so a
|
||||
# rejected update must leave the running configuration untouched rather
|
||||
# than half-applied. `hours` is constrained to be non-negative.
|
||||
before = config_eos.prediction.hours
|
||||
with pytest.raises(ValueError):
|
||||
config_eos.merge_settings_from_dict({"prediction": {"hours": -1}})
|
||||
assert config_eos.prediction.hours == before
|
||||
|
||||
|
||||
def test_disabled_ac_conversion_cannot_earn_negative_price_revenue():
|
||||
bat, inv = devices()
|
||||
inv.parameters.ac_to_dc_efficiency = 0
|
||||
c = build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array([-0.001, 0.001]),
|
||||
feed_in_euro_per_wh=np.array([0.0, 0.001]),
|
||||
load_wh=np.zeros(2),
|
||||
pv_wh=np.zeros(2),
|
||||
continuation=TerminalValueCurve(),
|
||||
charge_rates=[1],
|
||||
export_rates=[1],
|
||||
direct_marketing=True,
|
||||
)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
assert bat.soc_wh == 500 # Building the tail never mutates the real battery.
|
||||
|
||||
|
||||
def test_short_native_forecast_declares_its_resolution(config_eos):
|
||||
opt, params = setup_run(config_eos, interval=900, hours=52 * 4)
|
||||
params.forecast_interval_seconds = 900
|
||||
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.effective_tail_hours == 28
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,reason",
|
||||
[
|
||||
("electricity_price_per_wh", "import price"),
|
||||
("feed_in_tariff_per_wh", "feed-in tariff"),
|
||||
],
|
||||
)
|
||||
def test_differing_provider_lengths_use_the_common_tail(config_eos, field, reason):
|
||||
opt, params = setup_run(config_eos)
|
||||
setattr(params.ems, field, getattr(params.ems, field)[:52])
|
||||
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.effective_tail_hours == 28
|
||||
assert reason in result.terminal_value.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_provider_key_stays_missing():
|
||||
from types import SimpleNamespace
|
||||
|
||||
async def unavailable(*a, **kw):
|
||||
raise KeyError("price unavailable")
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
result = await bounded_forecast_array(
|
||||
SimpleNamespace(key_to_raw_series=unavailable),
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=2),
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
assert np.isnan(result).all()
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_short_prediction_horizon_shortens_the_tail(config_eos):
|
||||
# The forecast budget cannot serve the full 48 h tail. The run keeps going
|
||||
# with the 12 h that are left after the control horizon instead of failing.
|
||||
opt, params = setup_run(config_eos, hours=36, prediction_hours=36)
|
||||
assert opt.control_slots == 24
|
||||
assert opt.tail_slots == 12
|
||||
|
||||
result = opt.optimize_ems(params, ngen=2)
|
||||
assert result.terminal_value.mode == "TAIL"
|
||||
assert result.terminal_value.requested_tail_hours == 48
|
||||
assert result.terminal_value.effective_tail_hours == 12
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Native GENETIC results retain the elapsed-time grid and immutable run inputs."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"timestamp",
|
||||
["2026-03-29T03:15:00+02:00", "2026-10-25T02:30:00+02:00", "2026-10-25T02:30:00+01:00"],
|
||||
)
|
||||
async def test_native_result_retains_dst_grid_and_owned_inputs(config_eos, timestamp):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"general": {"latitude": 52.52, "longitude": 13.405},
|
||||
"prediction": {"hours": 24},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"interval_sec": 900,
|
||||
"horizon_hours": 1,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
start = to_datetime(timestamp).in_timezone("Europe/Berlin")
|
||||
get_ems(init=True).set_start_datetime(start)
|
||||
elapsed_slots = int((start - start.start_of("day")).total_seconds() // 900)
|
||||
count = elapsed_slots + 4
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
{
|
||||
"forecast_interval_seconds": 900,
|
||||
"ems": {
|
||||
"pv_forecast_wh": [0.0] * count,
|
||||
"total_load": [25.0] * count,
|
||||
"electricity_price_per_wh": [0.0003] * count,
|
||||
"feed_in_tariff_per_wh": [0.00005] * count,
|
||||
"price_per_wh_battery": 0.0,
|
||||
},
|
||||
"pv_battery": {
|
||||
"device_id": "owned_battery",
|
||||
"capacity_wh": 1000,
|
||||
"initial_soc_percentage": 50,
|
||||
},
|
||||
"inverter": {
|
||||
"device_id": "owned_inverter",
|
||||
"battery_id": "owned_battery",
|
||||
"max_power_wh": 1000,
|
||||
},
|
||||
"ev": None,
|
||||
}
|
||||
)
|
||||
optimizer = GeneticOptimization(fixed_seed=42)
|
||||
native = optimizer.optimize_ems(parameters, ngen=1, individuals=6)
|
||||
repeat = GeneticOptimization(fixed_seed=42).optimize_ems(parameters, ngen=1, individuals=6)
|
||||
assert native.start_solution == repeat.start_solution
|
||||
assert native.interval_seconds == 900
|
||||
assert native.start_solution_datetime == start
|
||||
assert native.controls_start_at_now
|
||||
assert len(native.result.load_wh_per_hour) == 4
|
||||
assert native.parameters.ems.total_load == [25.0] * 4
|
||||
parameters.ems.total_load[elapsed_slots] = 999.0
|
||||
get_ems().set_start_datetime(start.add(days=1))
|
||||
config_eos.optimization.genetic.interval_sec = 3600
|
||||
with patch(
|
||||
"akkudoktoreos.optimization.genetic.geneticsolution.get_prediction",
|
||||
side_effect=AssertionError("Native serialization must not reread providers"),
|
||||
):
|
||||
generic = await native.optimization_solution()
|
||||
plan = native.energy_management_plan()
|
||||
assert generic.valid_from == start
|
||||
assert generic.valid_until == start.add(hours=1)
|
||||
assert plan.valid_from == start
|
||||
assert plan.valid_until is None
|
||||
assert all(
|
||||
start.timestamp() <= item.execution_time.timestamp() < start.add(hours=1).timestamp()
|
||||
for item in plan.instructions
|
||||
)
|
||||
forecast = generic.prediction.to_dataframe()
|
||||
np.testing.assert_allclose(forecast["loadforecast_energy_wh"], [25.0] * 4)
|
||||
np.testing.assert_allclose(forecast["elec_price_amt_kwh"], [0.3] * 4)
|
||||
assert all(
|
||||
(right - left).total_seconds() == 900
|
||||
for left, right in zip(forecast.index, forecast.index[1:])
|
||||
)
|
||||
assert "owned_battery_soc_factor" in generic.solution.to_dataframe().columns
|
||||
|
||||
|
||||
def test_native_quarter_hour_temperatures_average_when_coarsened(config_eos):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"optimization": {"genetic": {"interval_sec": 3600, "horizon_hours": 1}}}
|
||||
)
|
||||
get_ems(init=True).set_start_datetime(
|
||||
to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
)
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
{
|
||||
"forecast_interval_seconds": 900,
|
||||
"temperature_forecast": [10, 12, 14, 16],
|
||||
"ems": {
|
||||
"pv_forecast_wh": [25.0] * 4,
|
||||
"total_load": [50.0] * 4,
|
||||
"electricity_price_per_wh": [0.0003] * 4,
|
||||
"feed_in_tariff_per_wh": 0.00005,
|
||||
"price_per_wh_battery": 0.0,
|
||||
},
|
||||
"pv_battery": None,
|
||||
"ev": None,
|
||||
"inverter": None,
|
||||
}
|
||||
)
|
||||
normalized = GeneticOptimization()._parameters_for_slot_grid(parameters)
|
||||
assert normalized.temperature_forecast == [13.0]
|
||||
assert normalized.ems.pv_forecast_wh == [100.0]
|
||||
assert normalized.ems.total_load == [200.0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_timezone", ["UTC", "Europe/Berlin"])
|
||||
@pytest.mark.parametrize("offset", ["+02:00", "+01:00"])
|
||||
@pytest.mark.parametrize("as_json_string", [False, True])
|
||||
def test_snapshot_and_warm_start_preserve_aware_instants(
|
||||
config_eos, set_other_timezone, host_timezone, offset, as_json_string
|
||||
):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from akkudoktoreos.optimization.genetic.configrequest import (
|
||||
ConfigOptimizationRequest,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
|
||||
set_other_timezone(host_timezone)
|
||||
expected = to_datetime(f"2026-10-25T02:30:00{offset}", in_timezone="Europe/Berlin")
|
||||
supplied = expected.to_iso8601_string() if as_json_string else expected
|
||||
payload = json.loads(
|
||||
(Path(__file__).parent / "testdata/genetic/optimize_result_1.json").read_text()
|
||||
)
|
||||
payload["start_solution_datetime"] = supplied
|
||||
native = GeneticSolution.model_validate(payload)
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
native.parameters.model_dump() | {"start_solution_datetime": supplied}
|
||||
)
|
||||
request = ConfigOptimizationRequest.model_validate({"start_solution_datetime": supplied})
|
||||
for model in (native, parameters, request):
|
||||
value = model.start_solution_datetime
|
||||
assert value is not None
|
||||
assert value.timestamp() == expected.timestamp()
|
||||
assert value.utcoffset() == expected.utcoffset()
|
||||
if not as_json_string:
|
||||
assert value.timezone_name == "Europe/Berlin"
|
||||
assert value.fold == expected.fold
|
||||
restored = type(model).model_validate_json(model.model_dump_json())
|
||||
assert restored.start_solution_datetime is not None
|
||||
assert restored.start_solution_datetime.timestamp() == expected.timestamp()
|
||||
assert restored.start_solution_datetime.utcoffset() == expected.utcoffset()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Real small optimizer runs covering device contracts across the public result."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.coreabc import get_ems, get_measurement
|
||||
from akkudoktoreos.core.emplan import DDBCInstruction
|
||||
from akkudoktoreos.measurement.measurement import Measurement
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, params=["UTC", "Europe/Berlin"])
|
||||
def local_clock(request: pytest.FixtureRequest, set_other_timezone: Callable[[str], str]) -> None:
|
||||
"""Run the same local-wall-clock schedules in UTC and a DST-observing zone.
|
||||
|
||||
Scenario dates intentionally have no fixed offset: each names local midnight,
|
||||
a local time window or a local departure in the selected timezone.
|
||||
"""
|
||||
set_other_timezone(request.param)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def isolated_measurement(config_eos: ConfigEOS) -> AsyncGenerator[Measurement, None]:
|
||||
"""Keep synthetic records out of the process-wide measurement singleton."""
|
||||
measurement = get_measurement()
|
||||
await measurement.delete_by_datetime(None, None)
|
||||
try:
|
||||
yield measurement
|
||||
finally:
|
||||
await measurement.delete_by_datetime(None, None)
|
||||
|
||||
|
||||
def configure(
|
||||
config: ConfigEOS,
|
||||
*,
|
||||
hours: int = 4,
|
||||
start: str = "2026-09-16T00:00:00",
|
||||
marketing: bool = False,
|
||||
) -> None:
|
||||
config.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": max(48, hours)},
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"genetic": {
|
||||
"horizon_hours": hours,
|
||||
"tail_horizon_hours": 0,
|
||||
"interval_sec": 900,
|
||||
"individuals": 12,
|
||||
"generations": 10,
|
||||
"terminal_value_mode": "FIXED",
|
||||
},
|
||||
},
|
||||
"feedintariff": {"direct_marketing_enabled": marketing},
|
||||
}
|
||||
)
|
||||
get_ems(init=True).set_start_datetime(to_datetime(start))
|
||||
|
||||
|
||||
def parameters(
|
||||
*, hours: int = 4, consumers: list[dict[str, Any]] | None = None, **kwargs: Any
|
||||
) -> GeneticOptimizationParameters:
|
||||
slots = hours * 4 + get_ems().start_datetime.hour * 4 + get_ems().start_datetime.minute // 15
|
||||
return GeneticOptimizationParameters.model_validate(
|
||||
{
|
||||
"ems": {
|
||||
"pv_prognose_wh": [0.0] * slots,
|
||||
"gesamtlast": [0.0] * slots,
|
||||
"strompreis_euro_pro_wh": [0.0003] * slots,
|
||||
"einspeiseverguetung_euro_pro_wh": [0.0001] * slots,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
},
|
||||
"inverter": {"device_id": "inv", "max_power_wh": 10000},
|
||||
"forecast_interval_seconds": 900,
|
||||
"home_appliances": consumers,
|
||||
"pv_battery": None,
|
||||
"ev": None,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def run(params: GeneticOptimizationParameters) -> tuple[GeneticOptimization, GeneticSolution]:
|
||||
optimizer = GeneticOptimization(fixed_seed=42)
|
||||
solution = optimizer.optimize_ems(params, ngen=1, individuals=12)
|
||||
return optimizer, solution
|
||||
|
||||
|
||||
def run_local_starts(solution: GeneticSolution) -> list[DateTime]:
|
||||
"""Compare scheduled instants in the run zone, independently of output zone."""
|
||||
timezone = get_ems().start_datetime.timezone_name
|
||||
assert timezone is not None
|
||||
return [moment.in_timezone(timezone) for moment in solution.appliance_starts["washer"]]
|
||||
|
||||
|
||||
def profile(**kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"device_id": "washer",
|
||||
"load_profile_power_w": [1200.0, 600.0, 300.0],
|
||||
"load_profile_interval_seconds": 600,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
|
||||
def test_real_optimizer_keeps_reverse_cycle_window_identity(config_eos: ConfigEOS) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(
|
||||
num_cycles=2,
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "02:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
_, solution = run(parameters(consumers=[consumer]))
|
||||
assert [value.hour for value in run_local_starts(solution)] == [1, 2]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(700.0)
|
||||
assert sum(solution.result.grid_consumption_wh_per_hour) == pytest.approx(700.0)
|
||||
assert solution.result.total_costs == pytest.approx(0.21)
|
||||
|
||||
|
||||
def test_real_daily_optimizer_skips_completed_cycles_only_on_first_day(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos, hours=28)
|
||||
consumer = profile(
|
||||
num_cycles=2,
|
||||
completed_cycles=1,
|
||||
schedule_mode="DAILY",
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
_, solution = run(parameters(hours=28, consumers=[consumer]))
|
||||
assert [(value.day, value.hour) for value in run_local_starts(solution)] == [
|
||||
(16, 2),
|
||||
(17, 1),
|
||||
(17, 2),
|
||||
]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(1050.0)
|
||||
|
||||
|
||||
def test_real_best_effort_multicycle_prioritizes_delay_over_cheaper_prices(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(num_cycles=2, min_cycle_gap_h=1, deadline_datetime="2026-09-15T23:00:00")
|
||||
params = parameters(consumers=[consumer])
|
||||
params.ems.electricity_price_per_wh = [0.001] * 8 + [-0.001] * 8
|
||||
_, solution = run(params)
|
||||
assert [(value.hour, value.minute) for value in run_local_starts(solution)] == [
|
||||
(0, 0),
|
||||
(1, 30),
|
||||
]
|
||||
assert solution.appliance_deadline_missed["washer"]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(700.0)
|
||||
|
||||
|
||||
def test_real_mixed_best_effort_cycle_status_survives_later_strict_cycle(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(
|
||||
num_cycles=2,
|
||||
deadline_datetime="2026-09-16T01:00:00",
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "02:00", "duration": "2 hours", "value": 0},
|
||||
{"start_time": "00:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
params = parameters(consumers=[consumer])
|
||||
params.ems.electricity_price_per_wh = [0.001] * 12 + [-0.001] * 4
|
||||
_, solution = run(params)
|
||||
assert [(value.hour, value.minute) for value in run_local_starts(solution)] == [
|
||||
(0, 0),
|
||||
(2, 0),
|
||||
]
|
||||
assert solution.appliance_deadline_missed["washer"]
|
||||
|
||||
|
||||
def test_real_warm_start_handles_changed_completed_cycle_layout(config_eos: ConfigEOS) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(
|
||||
num_cycles=2,
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
optimizer, first = run(parameters(consumers=[consumer]))
|
||||
consumer["completed_cycles"] = 1
|
||||
followup = parameters(
|
||||
consumers=[consumer],
|
||||
start_solution=first.start_solution,
|
||||
start_solution_datetime=first.start_solution_datetime,
|
||||
)
|
||||
second = optimizer.optimize_ems(followup, ngen=1, individuals=12)
|
||||
assert len(second.appliance_starts["washer"]) == 1
|
||||
assert run_local_starts(second)[0].hour == 2
|
||||
assert sum(second.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||||
assert first.start_solution is not None and second.start_solution is not None
|
||||
assert len(second.start_solution) == len(first.start_solution) - 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deadline", ["2026-09-15T23:00:00", "2026-09-16T00:01:00"])
|
||||
def test_real_ev_does_not_credit_energy_delivered_after_departure(
|
||||
config_eos: ConfigEOS, deadline: str
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
params = parameters(
|
||||
ev={
|
||||
"device_id": "car",
|
||||
"capacity_wh": 4000,
|
||||
"initial_soc_percentage": 0,
|
||||
"min_soc_percentage": 25,
|
||||
"charging_efficiency": 1.0,
|
||||
"max_charge_power_w": 4000,
|
||||
"charge_rates": [0.0, 1.0],
|
||||
"min_soc_deadline_datetime": deadline,
|
||||
}
|
||||
)
|
||||
optimizer, solution = run(params)
|
||||
assert optimizer._ev_soc_deadline_slot == 0
|
||||
assert (
|
||||
optimizer._ev_soc_at_deadline({"EAuto_SoC_pro_Stunde": solution.result.ev_soc_per_hour}, 0)
|
||||
== 0.0
|
||||
)
|
||||
|
||||
|
||||
def test_real_ev_target_across_midnight_uses_elapsed_slots(config_eos: ConfigEOS) -> None:
|
||||
configure(config_eos, start="2026-09-16T23:30:00")
|
||||
params = parameters(
|
||||
ev={
|
||||
"device_id": "car",
|
||||
"capacity_wh": 4000,
|
||||
"initial_soc_percentage": 0,
|
||||
"min_soc_percentage": 50,
|
||||
"charging_efficiency": 1.0,
|
||||
"max_charge_power_w": 4000,
|
||||
"charge_rates": [0.0, 1.0],
|
||||
"min_soc_deadline_datetime": "2026-09-17T00:00:00",
|
||||
}
|
||||
)
|
||||
params.ems.electricity_price_per_wh[-16:] = [0.001] * 2 + [0.00001] * 14
|
||||
optimizer, solution = run(params)
|
||||
assert optimizer._ev_soc_deadline_slot == 2
|
||||
assert solution.result.ev_soc_per_hour[2] >= 50
|
||||
assert solution.ev_charge_hours_float is not None
|
||||
assert solution.ev_charge_hours_float[:2] == [1.0, 1.0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"marketing,lcos,export_expected",
|
||||
[(False, 0.0, False), (True, 0.0, True), (True, 0.2, True), (True, 2.0, False)],
|
||||
)
|
||||
def test_real_export_respects_marketing_gate_and_storage_cost(
|
||||
config_eos: ConfigEOS, marketing: bool, lcos: float, export_expected: bool
|
||||
) -> None:
|
||||
configure(config_eos, marketing=marketing)
|
||||
params = parameters(
|
||||
pv_battery={
|
||||
"device_id": "battery",
|
||||
"capacity_wh": 1000,
|
||||
"initial_soc_percentage": 100,
|
||||
"charging_efficiency": 1.0,
|
||||
"discharging_efficiency": 1.0,
|
||||
"min_soc_percentage": 0,
|
||||
"max_charge_power_w": 4000,
|
||||
"grid_export_rates": [0.5, 1.0],
|
||||
"levelized_cost_of_storage_kwh": lcos,
|
||||
},
|
||||
inverter={"device_id": "inv", "max_power_wh": 10000, "battery_id": "battery"},
|
||||
)
|
||||
params.ems.feed_in_tariff_per_wh = [0.00001] + [0.001] * 4 + [0.00001] * 11
|
||||
_, solution = run(params)
|
||||
exported = sum(solution.result.grid_feed_in_wh_per_hour)
|
||||
if export_expected:
|
||||
assert exported == pytest.approx(1000.0)
|
||||
assert solution.result.total_revenue == pytest.approx(1.0)
|
||||
assert solution.result.total_costs == pytest.approx(lcos)
|
||||
assert any(solution.battery_grid_export_allowed)
|
||||
else:
|
||||
assert exported == pytest.approx(0.0)
|
||||
assert not any(solution.battery_grid_export_allowed)
|
||||
assert np.isfinite(solution.result.total_balance)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("custom_key", [None, "washer.completed_today"])
|
||||
async def test_real_measurement_completed_cycles_reach_request_and_optimizer(
|
||||
config_eos: ConfigEOS, custom_key: str | None, isolated_measurement: Measurement
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"devices": {
|
||||
"max_batteries": 0,
|
||||
"batteries": {},
|
||||
"max_electric_vehicles": 0,
|
||||
"electric_vehicles": {},
|
||||
"max_inverters": 1,
|
||||
"inverters": {"inv": {"max_power_w": 10000}},
|
||||
"max_home_appliances": 1,
|
||||
"home_appliances": {
|
||||
"washer": profile(
|
||||
num_cycles=2,
|
||||
cycles_completed_measurement_key=custom_key,
|
||||
cycle_time_windows={
|
||||
"windows": [
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
key = custom_key or "washer.cycles_completed"
|
||||
assert key in config_eos.devices.measurement_keys
|
||||
measurement = isolated_measurement
|
||||
zero = get_ems().start_datetime
|
||||
await measurement.update_value(zero.subtract(days=1), key, 2.0)
|
||||
await measurement.update_value(zero, key, 1.0)
|
||||
await measurement.update_value(zero.add(seconds=1), key, 2.0)
|
||||
dates, counts = await measurement.key_to_lists(
|
||||
key=key, start_datetime=zero, end_datetime=zero.add(seconds=1)
|
||||
)
|
||||
assert len(dates) == 1 and counts == [1.0]
|
||||
prepared = await ConfigOptimizationRequest.model_validate(
|
||||
{
|
||||
"forecasts": {
|
||||
"pv_forecast_wh": [0.0] * 16,
|
||||
"total_load": [0.0] * 16,
|
||||
"electricity_price_per_wh": [0.0003] * 16,
|
||||
"feed_in_tariff_per_wh": [0.0001] * 16,
|
||||
}
|
||||
}
|
||||
).resolve()
|
||||
assert prepared.home_appliances is not None
|
||||
assert prepared.home_appliances[0].completed_cycles == 1
|
||||
_, solution = run(prepared)
|
||||
assert [start.hour for start in run_local_starts(solution)] == [2]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_multiple_consumers_keep_separate_solution_channels(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
consumers = [
|
||||
profile(
|
||||
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "30 minutes"}]}
|
||||
),
|
||||
profile(
|
||||
device_id="dryer",
|
||||
load_profile_power_w=[2000.0],
|
||||
load_profile_interval_seconds=900,
|
||||
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "15 minutes"}]},
|
||||
),
|
||||
]
|
||||
_, solution = run(parameters(consumers=consumers))
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||||
assert sum(solution.result.home_appliance_energy_wh["dryer"]) == pytest.approx(500.0)
|
||||
assert sum(solution.result.grid_consumption_wh_per_hour) == pytest.approx(850.0)
|
||||
exported = await solution.optimization_solution()
|
||||
table = exported.solution.to_dataframe()
|
||||
assert table["washer_energy_wh"].sum() == pytest.approx(350.0)
|
||||
assert table["dryer_energy_wh"].sum() == pytest.approx(500.0)
|
||||
assert table.index[4].hour == 1
|
||||
assert table["washer_run_op_mode"].tolist()[4:6] == [1.0, 1.0]
|
||||
assert table["dryer_run_op_mode"].tolist()[4:6] == [1.0, 0.0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_profile_zero_power_phase_keeps_device_running_until_complete(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(
|
||||
load_profile_power_w=[1200.0, 0.0, 1200.0],
|
||||
load_profile_interval_seconds=900,
|
||||
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "45 minutes"}]},
|
||||
)
|
||||
_, solution = run(parameters(consumers=[consumer]))
|
||||
exported = await solution.optimization_solution()
|
||||
table = exported.solution.to_dataframe()
|
||||
assert table["washer_energy_wh"].tolist()[4:7] == [300.0, 0.0, 300.0]
|
||||
assert table["washer_run_op_mode"].tolist()[4:7] == [1.0, 1.0, 1.0]
|
||||
plan = solution.energy_management_plan()
|
||||
assert {item.resource_id for item in plan.instructions} == {"washer"}
|
||||
commands = [
|
||||
(item.execution_time.hour, item.execution_time.minute, str(item.operation_mode_id))
|
||||
for item in plan.instructions
|
||||
if isinstance(item, DDBCInstruction)
|
||||
]
|
||||
assert commands == [(0, 0, "OFF"), (1, 0, "RUN"), (1, 45, "OFF")]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Raw forecast coverage must survive resampling without inventing valid intervals."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("drop,missing", [(1, 4), (2, 8)])
|
||||
async def test_hourly_gaps_and_unavailable_tail_are_not_forward_filled(drop, missing):
|
||||
index = pd.date_range("2026-09-16T00:00:00Z", periods=4, freq="h").delete(drop)
|
||||
prediction = Mock(key_to_raw_series=AsyncMock(return_value=pd.Series([100.0] * 3, index=index)))
|
||||
start = to_datetime("2026-09-16T00:00:00Z", in_timezone="UTC")
|
||||
values = await bounded_forecast_array(
|
||||
prediction,
|
||||
key="pv",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=5),
|
||||
interval=to_duration(900),
|
||||
)
|
||||
assert np.isnan(values[missing : missing + 4]).all()
|
||||
assert np.isnan(values[16:]).all()
|
||||
assert np.isfinite(values[:4]).all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downsampling_requires_complete_coverage_and_uses_interval_average():
|
||||
index = pd.date_range("2026-09-16T00:00:00Z", periods=8, freq="15min")
|
||||
series = pd.Series([100.0, 200.0, 300.0, 400.0, 100.0, np.nan, 100.0, 100.0], index=index)
|
||||
prediction = Mock(key_to_raw_series=AsyncMock(return_value=series))
|
||||
start = to_datetime("2026-09-16T00:00:00Z", in_timezone="UTC")
|
||||
values = await bounded_forecast_array(
|
||||
prediction,
|
||||
key="pv",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=2),
|
||||
interval=to_duration(3600),
|
||||
)
|
||||
assert values[0] == 250.0
|
||||
assert np.isnan(values[1])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_aligned_source_intervals_are_weighted_by_actual_overlap():
|
||||
index = pd.date_range("2026-09-16T00:05:00Z", periods=4, freq="15min")
|
||||
prediction = Mock(
|
||||
key_to_raw_series=AsyncMock(
|
||||
return_value=pd.Series([100.0, 400.0, 700.0, 1000.0], index=index)
|
||||
)
|
||||
)
|
||||
start = to_datetime("2026-09-16T00:00:00Z", in_timezone="UTC")
|
||||
values = await bounded_forecast_array(
|
||||
prediction,
|
||||
key="pv",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=1),
|
||||
interval=to_duration(900),
|
||||
)
|
||||
assert np.isnan(values[0])
|
||||
assert values[1] == pytest.approx(300.0)
|
||||
assert values[2] == pytest.approx(600.0)
|
||||
@@ -0,0 +1,476 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from deap import creator, tools
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
def _configure_hourly_grid(config_eos: ConfigEOS, *, start_hour: int = 0) -> None:
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval_sec": 3600}
|
||||
},
|
||||
}
|
||||
)
|
||||
get_ems(init=True).set_start_datetime(to_datetime().set(hour=start_hour, minute=0))
|
||||
|
||||
|
||||
def test_ev_repair_is_resimulated_before_fitness_assignment(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = True
|
||||
opt.ev_possible_charge_values = [0.0, 1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
individual = creator.Individual([0] * opt.control_slots + [1] * opt.control_slots)
|
||||
|
||||
first_result = {
|
||||
"Gesamtbilanz_Euro": 10.0,
|
||||
"Gesamt_Verluste": 0.0,
|
||||
"EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0),
|
||||
}
|
||||
repaired_result = {
|
||||
"Gesamtbilanz_Euro": 1.0,
|
||||
"Gesamt_Verluste": 0.0,
|
||||
"EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0),
|
||||
}
|
||||
parameters = SimpleNamespace(
|
||||
ems=SimpleNamespace(price_per_wh_battery=0.0),
|
||||
ev=None,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
opt, "evaluate_inner", side_effect=[first_result, repaired_result]
|
||||
) as evaluate:
|
||||
fitness = opt.evaluate(individual, parameters, start_hour=0, worst_case=False) # type: ignore[arg-type]
|
||||
|
||||
assert evaluate.call_count == 2
|
||||
assert fitness == pytest.approx((1.0,))
|
||||
assert individual[opt.control_slots :] == [0] * opt.control_slots
|
||||
|
||||
|
||||
def test_fitness_cache_restores_canonical_ev_genome(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = True
|
||||
opt.ev_possible_charge_values = [0.0, 1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
parameters = SimpleNamespace(
|
||||
ems=SimpleNamespace(price_per_wh_battery=0.0),
|
||||
ev=None,
|
||||
)
|
||||
result = {
|
||||
"Gesamtbilanz_Euro": 1.0,
|
||||
"Gesamt_Verluste": 0.0,
|
||||
"EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0),
|
||||
}
|
||||
first = creator.Individual([0] * opt.control_slots + [1] * opt.control_slots)
|
||||
duplicate = creator.Individual(first)
|
||||
opt._fitness_cache_enabled = True
|
||||
|
||||
with patch.object(opt, "evaluate_inner", return_value=result) as evaluate:
|
||||
first_fitness = opt.evaluate(first, parameters, 0, False) # type: ignore[arg-type]
|
||||
duplicate_fitness = opt.evaluate(duplicate, parameters, 0, False) # type: ignore[arg-type]
|
||||
|
||||
# The miss evaluates and then re-evaluates the repaired EV plan. The duplicate
|
||||
# is served directly from the original-key alias and receives the canonical genome.
|
||||
assert evaluate.call_count == 2
|
||||
assert first_fitness == duplicate_fitness
|
||||
assert duplicate == first
|
||||
assert duplicate[opt.control_slots :] == [0] * opt.control_slots
|
||||
assert duplicate.extra_data == first.extra_data
|
||||
assert opt._fitness_cache_hits == 1
|
||||
assert opt._fitness_cache_misses == 1
|
||||
|
||||
|
||||
def test_fitness_cache_never_stores_failed_evaluations(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
parameters = SimpleNamespace(
|
||||
ems=SimpleNamespace(price_per_wh_battery=0.0),
|
||||
ev=None,
|
||||
)
|
||||
first = creator.Individual([0] * opt.control_slots)
|
||||
duplicate = creator.Individual(first)
|
||||
opt._fitness_cache_enabled = True
|
||||
|
||||
with patch.object(opt, "evaluate_inner", side_effect=RuntimeError("transient")) as evaluate:
|
||||
assert opt.evaluate(first, parameters, 0, False) == (100000.0,) # type: ignore[arg-type]
|
||||
assert opt.evaluate(duplicate, parameters, 0, False) == (100000.0,) # type: ignore[arg-type]
|
||||
|
||||
assert evaluate.call_count == 2
|
||||
assert opt._fitness_cache_hits == 0
|
||||
assert opt._fitness_cache_misses == 2
|
||||
assert opt._fitness_cache == {}
|
||||
|
||||
|
||||
def test_fitness_cache_includes_first_run_relative_control(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos, start_hour=10)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=10)
|
||||
parameters = SimpleNamespace(
|
||||
ems=SimpleNamespace(price_per_wh_battery=0.0),
|
||||
ev=None,
|
||||
)
|
||||
result = {
|
||||
"Gesamtbilanz_Euro": 1.0,
|
||||
"Gesamt_Verluste": 0.0,
|
||||
"EAuto_SoC_pro_Stunde": np.zeros(opt.control_slots),
|
||||
}
|
||||
first = creator.Individual([0] * opt.control_slots)
|
||||
elapsed_variant = creator.Individual(first)
|
||||
elapsed_variant[0] = 1
|
||||
opt._fitness_cache_enabled = True
|
||||
|
||||
with patch.object(opt, "evaluate_inner", return_value=result) as evaluate:
|
||||
first_fitness = opt.evaluate(first, parameters, 10, False) # type: ignore[arg-type]
|
||||
variant_fitness = opt.evaluate(elapsed_variant, parameters, 10, False) # type: ignore[arg-type]
|
||||
|
||||
assert evaluate.call_count == 2
|
||||
assert first_fitness == variant_fitness
|
||||
assert opt._fitness_cache_hits == 0
|
||||
|
||||
|
||||
def test_mutated_warm_start_neighbors_stay_within_control_horizon(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos, start_hour=10)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=10)
|
||||
start_solution = [0.0] * opt.control_slots
|
||||
|
||||
neighbors = opt._mutated_warm_start_neighbors(start_solution, count=5)
|
||||
|
||||
assert len(neighbors) == 5
|
||||
assert len({tuple(neighbor) for neighbor in neighbors}) == 5
|
||||
assert all(len(neighbor) == opt.control_slots for neighbor in neighbors)
|
||||
assert all(neighbor != start_solution for neighbor in neighbors)
|
||||
|
||||
|
||||
def test_initial_population_uses_fixed_seed_budget_and_configured_population(
|
||||
config_eos: ConfigEOS,
|
||||
):
|
||||
_configure_hourly_grid(config_eos)
|
||||
config_eos.optimization.genetic.individuals = 300
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
start_solution = [5.0] * opt.control_slots
|
||||
warm_neighbors = [[6] * opt.control_slots for _ in range(50)]
|
||||
educated = [[7] * opt.control_slots for _ in range(100)]
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_evolution(population, **kwargs):
|
||||
captured["population"] = list(population)
|
||||
captured["mu"] = kwargs["mu"]
|
||||
captured["lambda"] = kwargs["lambda_"]
|
||||
for individual in population:
|
||||
individual.fitness.values = (float(sum(individual)),)
|
||||
individual.extra_data = (0.0, 0.0, 0.0)
|
||||
kwargs["halloffame"].update(population)
|
||||
return population, SimpleNamespace(select=lambda _name: [])
|
||||
|
||||
with (
|
||||
patch.object(opt, "_mutated_warm_start_neighbors", return_value=warm_neighbors),
|
||||
patch.object(opt, "_educated_guess_individuals", return_value=educated),
|
||||
patch.object(
|
||||
opt.toolbox,
|
||||
"population",
|
||||
side_effect=lambda n: [creator.Individual([9] * opt.control_slots) for _ in range(n)],
|
||||
),
|
||||
patch.object(opt, "_evolve_population_adaptive", side_effect=fake_evolution),
|
||||
):
|
||||
opt.optimize(start_solution=start_solution, ngen=1)
|
||||
|
||||
population = captured["population"]
|
||||
assert isinstance(population, list)
|
||||
first_genes = [individual[0] for individual in population]
|
||||
assert len(population) == 300
|
||||
assert first_genes.count(5) == 10
|
||||
assert first_genes.count(6) == 50
|
||||
assert first_genes.count(7) == 100
|
||||
assert first_genes.count(9) == 140
|
||||
assert captured["mu"] == 300
|
||||
assert captured["lambda"] == 300
|
||||
|
||||
|
||||
def test_small_population_scales_warm_and_educated_seed_families(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
config_eos.optimization.genetic.individuals = 100
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
start_solution = [5.0] * opt.control_slots
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def warm_neighbors(_solution, count):
|
||||
captured["warm_count"] = count
|
||||
return [[6] * opt.control_slots for _ in range(count)]
|
||||
|
||||
def educated(count):
|
||||
captured["educated_count"] = count
|
||||
return [[7] * opt.control_slots for _ in range(count)]
|
||||
|
||||
def fake_evolution(population, **kwargs):
|
||||
captured["population"] = list(population)
|
||||
captured["mu"] = kwargs["mu"]
|
||||
captured["lambda"] = kwargs["lambda_"]
|
||||
for individual in population:
|
||||
individual.fitness.values = (float(sum(individual)),)
|
||||
individual.extra_data = (0.0, 0.0, 0.0)
|
||||
kwargs["halloffame"].update(population)
|
||||
return population, SimpleNamespace(select=lambda _name: [])
|
||||
|
||||
with (
|
||||
patch.object(opt, "_mutated_warm_start_neighbors", side_effect=warm_neighbors),
|
||||
patch.object(opt, "_educated_guess_individuals", side_effect=educated),
|
||||
patch.object(
|
||||
opt.toolbox,
|
||||
"population",
|
||||
side_effect=lambda n: [creator.Individual([9] * opt.control_slots) for _ in range(n)],
|
||||
),
|
||||
patch.object(opt, "_evolve_population_adaptive", side_effect=fake_evolution),
|
||||
):
|
||||
opt.optimize(start_solution=start_solution, ngen=1)
|
||||
|
||||
population = captured["population"]
|
||||
assert isinstance(population, list)
|
||||
first_genes = [individual[0] for individual in population]
|
||||
assert len(population) == 100
|
||||
assert first_genes.count(5) == 10
|
||||
assert first_genes.count(6) == 20
|
||||
assert first_genes.count(7) == 40
|
||||
assert first_genes.count(9) == 30
|
||||
assert captured["warm_count"] == 20
|
||||
assert captured["educated_count"] == 40
|
||||
assert captured["mu"] == 100
|
||||
assert captured["lambda"] == 100
|
||||
|
||||
|
||||
def test_adaptive_evolution_soft_restarts_collapsed_population(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
opt.toolbox.register("evaluate", lambda individual: (float(sum(individual)),))
|
||||
population = [creator.Individual([0] * opt.control_slots) for _ in range(20)]
|
||||
stats = tools.Statistics(lambda individual: individual.fitness.values)
|
||||
stats.register("min", np.min)
|
||||
stats.register("avg", np.mean)
|
||||
stats.register("max", np.max)
|
||||
halloffame = tools.HallOfFame(1)
|
||||
|
||||
fresh = [creator.Individual([value] + [0] * (opt.control_slots - 1)) for value in range(1, 20)]
|
||||
with patch.object(opt, "_fresh_population", return_value=fresh) as create_fresh:
|
||||
evolved, log = opt._evolve_population_adaptive(
|
||||
population,
|
||||
mu=20,
|
||||
lambda_=20,
|
||||
ngen=1,
|
||||
stats=stats,
|
||||
halloffame=halloffame,
|
||||
)
|
||||
|
||||
create_fresh.assert_called_once_with(19, educated_fraction=0.40)
|
||||
assert log.select("restart") == [0, 1]
|
||||
assert log.select("immigrants") == [0, 19]
|
||||
assert opt._adaptive_evolution_metrics["soft_restarts"] == 1
|
||||
assert opt._population_diversity(evolved) == pytest.approx(1.0)
|
||||
assert halloffame[0].fitness.values == (0.0,)
|
||||
|
||||
|
||||
def test_local_search_moves_weak_export_to_later_expensive_import(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.optimize_dc_charge = True
|
||||
opt.optimize_battery_grid_export = True
|
||||
opt.bat_possible_charge_values = [1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
slots = opt.control_slots
|
||||
export_state = 5
|
||||
self_consumption_state = 6
|
||||
discharge_state = 1
|
||||
source = 10
|
||||
targets = list(range(20, 32))
|
||||
base = [self_consumption_state] * slots
|
||||
base[source] = export_state
|
||||
for slot in targets:
|
||||
base[slot] = 0
|
||||
|
||||
opt.simulation.elect_price_hourly = np.full(slots, 0.10)
|
||||
opt.simulation.elect_price_hourly[targets] = 0.30
|
||||
opt.simulation.elect_revenue_per_hour_arr = np.full(slots, 0.05)
|
||||
opt.simulation.elect_revenue_per_hour_arr[source] = 0.20
|
||||
opt.simulation.pv_prediction_wh = np.zeros(slots)
|
||||
opt.simulation.load_energy_array = np.full(slots, 100.0)
|
||||
|
||||
def evaluate(individual):
|
||||
export_value = -0.20 if individual[source] == export_state else 0.0
|
||||
avoided_import = -0.05 * sum(individual[slot] == discharge_state for slot in targets)
|
||||
return (export_value + avoided_import,)
|
||||
|
||||
opt.toolbox.register("evaluate", evaluate)
|
||||
incumbent = creator.Individual(base)
|
||||
incumbent.fitness.values = evaluate(incumbent)
|
||||
|
||||
best, evaluations, improvements, initial, final = opt._locally_improve_grid_export(
|
||||
incumbent,
|
||||
max_evaluations=96,
|
||||
)
|
||||
|
||||
assert evaluations > 0
|
||||
assert improvements == 1
|
||||
assert final < initial
|
||||
assert best[source] == self_consumption_state
|
||||
assert sum(best[slot] == discharge_state for slot in targets) >= 6
|
||||
|
||||
|
||||
def test_educated_guesses_encode_high_price_direct_marketing(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.optimize_dc_charge = True
|
||||
opt.optimize_battery_grid_export = True
|
||||
opt.bat_possible_charge_values = [1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
slots = opt.control_slots
|
||||
opt.simulation.elect_price_hourly = np.linspace(0.0001, 0.0004, slots)
|
||||
opt.simulation.elect_revenue_per_hour_arr = np.linspace(0.00001, 0.0003, slots)
|
||||
opt.simulation.pv_prediction_wh = np.full(slots, 1000.0)
|
||||
opt.simulation.load_energy_array = np.full(slots, 500.0)
|
||||
|
||||
guesses = opt._educated_guess_individuals()
|
||||
|
||||
dc_allowed_state = 4
|
||||
export_state = 5
|
||||
self_consumption_state = 6
|
||||
assert len(guesses) == opt.EDUCATED_GUESS_TARGET
|
||||
assert all(len(guess) == slots for guess in guesses)
|
||||
assert any(dc_allowed_state in guess or self_consumption_state in guess for guess in guesses)
|
||||
assert any(self_consumption_state in guess for guess in guesses)
|
||||
assert any(guess[-1] == export_state for guess in guesses)
|
||||
|
||||
|
||||
def test_flat_feed_in_tariff_does_not_seed_direct_marketing(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.optimize_dc_charge = True
|
||||
opt.optimize_battery_grid_export = True
|
||||
opt.bat_possible_charge_values = [1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
slots = opt.control_slots
|
||||
opt.simulation.elect_price_hourly = np.linspace(0.0001, 0.0004, slots)
|
||||
opt.simulation.elect_revenue_per_hour_arr = np.full(slots, 0.00005)
|
||||
opt.simulation.pv_prediction_wh = np.full(slots, 1000.0)
|
||||
opt.simulation.load_energy_array = np.full(slots, 500.0)
|
||||
|
||||
guesses = opt._educated_guess_individuals()
|
||||
|
||||
export_state = 5
|
||||
assert all(export_state not in guess for guess in guesses)
|
||||
|
||||
|
||||
def _rated(genome: list[int], fitness: float, *, protection: int = 0):
|
||||
"""Build an evaluated individual, optionally a protected immigrant."""
|
||||
individual = creator.Individual(genome)
|
||||
individual.fitness.values = (fitness,)
|
||||
if protection:
|
||||
individual.immigrant_protection = protection
|
||||
return individual
|
||||
|
||||
|
||||
def test_diversity_boost_threshold_stays_below_selection_floor():
|
||||
# The selection guarantees SELECTION_DIVERSITY_FLOOR unique genomes, so a
|
||||
# boost threshold at or above the floor would fire in every converged
|
||||
# generation and turn the boost into the normal operating state.
|
||||
assert (
|
||||
GeneticOptimization.DIVERSITY_BOOST_THRESHOLD
|
||||
< GeneticOptimization.SELECTION_DIVERSITY_FLOOR
|
||||
)
|
||||
|
||||
|
||||
def _immigrant_selection_pool(opt: GeneticOptimization, protection: int):
|
||||
"""Converged incumbents plus fresh immigrants that the tournament dislikes.
|
||||
|
||||
The incumbents already carry more unique genomes than
|
||||
``SELECTION_DIVERSITY_FLOOR`` demands, so the duplicate repair has no reason
|
||||
to reach for an immigrant and only the protection can seat one.
|
||||
"""
|
||||
slots = opt.control_slots
|
||||
incumbents = [
|
||||
_rated([1, index] + [0] * (slots - 2), -5.73 + index * 1e-4) for index in range(100)
|
||||
]
|
||||
offspring = [
|
||||
_rated([2, index] + [0] * (slots - 2), -5.72 + index * 1e-4) for index in range(88)
|
||||
]
|
||||
offspring.extend(
|
||||
_rated([3, index, index] + [0] * (slots - 3), 3.0 + index, protection=protection)
|
||||
for index in range(12)
|
||||
)
|
||||
return incumbents, offspring
|
||||
|
||||
|
||||
def test_protected_immigrants_survive_the_selection(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
seated = {}
|
||||
for protection in (0, opt.IMMIGRANT_PROTECTION_GENERATIONS):
|
||||
incumbents, offspring = _immigrant_selection_pool(opt, protection)
|
||||
selected = opt._select_diverse(incumbents + offspring, 100)
|
||||
seated[protection] = sum(1 for candidate in selected if candidate[0] == 3)
|
||||
# The incumbent is never evicted to make room for an immigrant.
|
||||
assert min(candidate.fitness.values[0] for candidate in selected) == pytest.approx(-5.73)
|
||||
|
||||
# Without protection the tournament removes every immigrant in the
|
||||
# generation it is born, so its genes never get to recombine.
|
||||
assert seated[0] == 0
|
||||
assert seated[opt.IMMIGRANT_PROTECTION_GENERATIONS] == 12
|
||||
|
||||
|
||||
def test_immigrant_protection_expires_after_its_generations(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
immigrants = [_rated([3, 0, 0], 3.0, protection=opt.IMMIGRANT_PROTECTION_GENERATIONS)]
|
||||
for _ in range(opt.IMMIGRANT_PROTECTION_GENERATIONS):
|
||||
assert immigrants[0].immigrant_protection > 0
|
||||
opt._age_immigrant_protection(immigrants)
|
||||
assert immigrants[0].immigrant_protection == 0
|
||||
|
||||
# Aging is idempotent once the protection is spent.
|
||||
opt._age_immigrant_protection(immigrants)
|
||||
assert immigrants[0].immigrant_protection == 0
|
||||
|
||||
|
||||
def test_offspring_do_not_inherit_immigrant_protection(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
opt.toolbox.register("evaluate", lambda individual: (float(sum(individual)),))
|
||||
|
||||
parents = [
|
||||
_rated([0] * opt.control_slots, 0.0, protection=opt.IMMIGRANT_PROTECTION_GENERATIONS)
|
||||
for _ in range(4)
|
||||
]
|
||||
offspring = opt._make_offspring(parents, 8, mutation_probability=1.0)
|
||||
assert all(getattr(child, "immigrant_protection", 0) == 0 for child in offspring)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""A warm start from an earlier run is aligned with the slot this run starts in.
|
||||
|
||||
Genomes are run-relative: gene 0 controls the slot the run starts in. Reusing
|
||||
the previous solution unchanged after a slot boundary describes every decision
|
||||
one slot too late, and a search that keeps the seed postpones a planned action
|
||||
by one slot per run.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime
|
||||
|
||||
|
||||
def _optimizer(
|
||||
config_eos: ConfigEOS,
|
||||
*,
|
||||
interval: int,
|
||||
optimize_ev: bool = False,
|
||||
n_appliance_genes: int = 0,
|
||||
) -> tuple[GeneticOptimization, DateTime]:
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 24, "interval_sec": interval}
|
||||
},
|
||||
}
|
||||
)
|
||||
slot0 = get_ems(init=True).set_start_datetime(to_datetime().set(hour=8, minute=0, second=0))
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = optimize_ev
|
||||
opt.appliance_layout = SimpleNamespace(n_genes=n_appliance_genes, genes=[]) # type: ignore[assignment]
|
||||
opt._slot0_datetime = slot0
|
||||
return opt, slot0
|
||||
|
||||
|
||||
def _genes(start: int, stop: int) -> list[float]:
|
||||
return [float(value) for value in range(start, stop)]
|
||||
|
||||
|
||||
def test_quarter_hour_warm_start_moves_one_slot_forward(config_eos: ConfigEOS):
|
||||
# 07:45 run: export genes (32) at 08:00 and 08:15. Unshifted, the 08:00 run
|
||||
# would read them as 08:15 and 08:30.
|
||||
opt, slot0 = _optimizer(config_eos, interval=900)
|
||||
previous = [19.0, 32.0, 32.0, 14.0] + [36.0] * (opt.control_slots - 4)
|
||||
|
||||
aligned = opt._start_solution_for_run_start(previous, slot0.subtract(minutes=15))
|
||||
|
||||
assert aligned == [32, 32, 14] + [36] * (opt.control_slots - 3)
|
||||
|
||||
|
||||
def test_elapsed_slots_are_dropped_per_block_and_appliance_genes_kept(config_eos: ConfigEOS):
|
||||
opt, slot0 = _optimizer(config_eos, interval=3600, optimize_ev=True, n_appliance_genes=1)
|
||||
slots = opt.control_slots
|
||||
battery = _genes(0, slots)
|
||||
ev = _genes(100, 100 + slots)
|
||||
appliance = [3.0]
|
||||
|
||||
aligned = opt._start_solution_for_run_start(battery + ev + appliance, slot0.subtract(hours=3))
|
||||
|
||||
assert aligned == (
|
||||
_genes(3, slots)
|
||||
+ [slots - 1.0] * 3
|
||||
+ _genes(103, 100 + slots)
|
||||
+ [100 + slots - 1.0] * 3
|
||||
+ appliance
|
||||
)
|
||||
|
||||
|
||||
def test_same_slot_or_unknown_start_keeps_warm_start(config_eos: ConfigEOS):
|
||||
opt, slot0 = _optimizer(config_eos, interval=900)
|
||||
previous = _genes(0, opt.control_slots)
|
||||
|
||||
assert opt._start_solution_for_run_start(previous, slot0) == previous
|
||||
assert opt._start_solution_for_run_start(previous, None) == previous
|
||||
assert opt._start_solution_for_run_start(None, slot0) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offset_minutes",
|
||||
[
|
||||
pytest.param(-24 * 60, id="all-control-slots-elapsed"),
|
||||
pytest.param(15, id="starts-after-this-run"),
|
||||
],
|
||||
)
|
||||
def test_unusable_warm_start_is_dropped(config_eos: ConfigEOS, offset_minutes: int):
|
||||
opt, slot0 = _optimizer(config_eos, interval=900)
|
||||
previous = _genes(0, opt.control_slots)
|
||||
|
||||
assert opt._start_solution_for_run_start(previous, slot0.add(minutes=offset_minutes)) is None
|
||||
|
||||
|
||||
def test_start_datetime_falls_back_to_last_solution_of_this_server(
|
||||
config_eos: ConfigEOS, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
opt, slot0 = _optimizer(config_eos, interval=900)
|
||||
last_start = slot0.subtract(minutes=15)
|
||||
monkeypatch.setattr(
|
||||
type(get_ems()),
|
||||
"_genetic_solution",
|
||||
SimpleNamespace(start_solution=[19.0, 32.0, 14.0], start_solution_datetime=last_start),
|
||||
)
|
||||
|
||||
same = SimpleNamespace(start_solution=[19, 32, 14], start_solution_datetime=None)
|
||||
other = SimpleNamespace(start_solution=[19, 32, 15], start_solution_datetime=None)
|
||||
explicit_start = slot0.subtract(minutes=30)
|
||||
explicit = SimpleNamespace(start_solution=[19, 32, 14], start_solution_datetime=explicit_start)
|
||||
|
||||
assert opt._resolve_start_solution_datetime(same) == last_start # type: ignore[arg-type]
|
||||
assert opt._resolve_start_solution_datetime(other) is None # type: ignore[arg-type]
|
||||
assert opt._resolve_start_solution_datetime(explicit) == explicit_start # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_parameters_accept_iso_start_solution_datetime(config_eos: ConfigEOS):
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems=GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[0.0, 0.0],
|
||||
strompreis_euro_pro_wh=[0.0, 0.0],
|
||||
einspeiseverguetung_euro_pro_wh=0.0,
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0, 0.0],
|
||||
)
|
||||
),
|
||||
pv_akku=None,
|
||||
inverter=None,
|
||||
eauto=None,
|
||||
start_solution=[1.0, 2.0],
|
||||
start_solution_datetime="2026-09-14T07:45:00+02:00",
|
||||
)
|
||||
)
|
||||
|
||||
assert parameters.start_solution_datetime is not None
|
||||
assert compare_datetimes(
|
||||
parameters.start_solution_datetime, to_datetime("2026-09-14T07:45:00+02:00")
|
||||
).equal
|
||||
@@ -2,11 +2,9 @@ import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from pypdf import PdfReader
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
@@ -22,7 +20,7 @@ from akkudoktoreos.optimization.genetic.geneticvisualize import (
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
ems_eos = get_ems(init=True) # init once
|
||||
ems_eos = get_ems(init=True) # init once
|
||||
|
||||
DIR_TESTDATA = Path(__file__).parent / "testdata" / "genetic"
|
||||
|
||||
@@ -40,6 +38,7 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]):
|
||||
else:
|
||||
assert actual[key] == pytest.approx(value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"fn_in, fn_out, ngen, break_even",
|
||||
@@ -67,30 +66,30 @@ async def test_optimize(
|
||||
# Assure configuration holds the correct values
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {
|
||||
"hours": 48
|
||||
},
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"genetic": {
|
||||
"horizon_hours": 48,
|
||||
"horizon_hours": 38,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
"individuals": 300,
|
||||
"generations": 10,
|
||||
"penalties": {
|
||||
"ev_soc_miss": 10,
|
||||
"ac_charge_break_even": break_even,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
"devices": {
|
||||
"max_electric_vehicles": 1,
|
||||
"electric_vehicles": { "ev1":
|
||||
{
|
||||
"electric_vehicles": {
|
||||
"ev1": {
|
||||
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Load input and output data
|
||||
@@ -99,7 +98,9 @@ async def test_optimize(
|
||||
input_data = GeneticOptimizationParameters(**json.load(f_in))
|
||||
|
||||
# Fake energy management run start datetime
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=fixed_start_hour))
|
||||
ems_eos.set_start_datetime(
|
||||
to_datetime("2026-09-16T10:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
)
|
||||
|
||||
# Throw away any cached results of the last energy management run.
|
||||
CacheEnergyManagementStore().clear()
|
||||
@@ -115,31 +116,9 @@ async def test_optimize(
|
||||
parameters=input_data, start_hour=fixed_start_hour, ngen=ngen
|
||||
)
|
||||
|
||||
# Write test output to file, so we can take it as new data on intended change
|
||||
TESTDATA_FILE = DIR_TESTDATA / f"new_{fn_out}"
|
||||
with TESTDATA_FILE.open("w", encoding="utf-8", newline="\n") as f_out:
|
||||
f_out.write(genetic_solution.model_dump_json(indent=4, exclude_unset=True))
|
||||
|
||||
solution_file = DIR_TESTDATA / fn_out
|
||||
# In case a new test case is added, we don't want to fail here, so the new output is written
|
||||
# to disk before
|
||||
try:
|
||||
with solution_file.open("r") as f_out:
|
||||
expected_data = json.load(f_out)
|
||||
expected_result = GeneticSolution(**expected_data)
|
||||
except ValidationError:
|
||||
# Expected genetic solution data does not fit to GeneticSolution data schema
|
||||
# Possibly the GeneticSolution class changed.
|
||||
pytest.fail(
|
||||
f"ValidationError: Can not load expected solution from {solution_file}\n"
|
||||
f"cp {TESTDATA_FILE} {solution_file}\n"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# Should not happen
|
||||
pytest.fail(
|
||||
f"FileNotFoundError: Can not load expected solution from {solution_file}\n"
|
||||
f"cp {TESTDATA_FILE} {solution_file}\n"
|
||||
)
|
||||
# Historical payloads still deserialize with deprecated English/German aliases.
|
||||
with (DIR_TESTDATA / fn_out).open("r") as expected_file:
|
||||
expected_result = GeneticSolution.model_validate(json.load(expected_file))
|
||||
|
||||
# Keep the output contract, but do not demand an identical stochastic
|
||||
# schedule or monetary golden from the previous direct-consumption model.
|
||||
@@ -148,10 +127,8 @@ async def test_optimize(
|
||||
expected_slots = len(input_data.ems.pv_forecast_wh) - fixed_start_hour
|
||||
assert len(result.grid_consumption_wh_per_hour) == expected_slots
|
||||
assert len(result.grid_feed_in_wh_per_hour) == expected_slots
|
||||
prices = np.asarray(genetic_solution.parameters.ems.electricity_price_per_wh)[fixed_start_hour:]
|
||||
tariffs = np.asarray(genetic_solution.parameters.ems.feed_in_tariff_per_wh)
|
||||
if tariffs.ndim > 0:
|
||||
tariffs = tariffs[fixed_start_hour:]
|
||||
prices = np.asarray(genetic_solution.parameters.ems.electricity_price_per_wh)
|
||||
tariffs = np.asarray(genetic_solution.parameters.ems.feed_in_tariff_per_wh)[:expected_slots]
|
||||
expected_costs = np.asarray(result.grid_consumption_wh_per_hour) * prices
|
||||
expected_revenues = np.asarray(result.grid_feed_in_wh_per_hour) * tariffs
|
||||
np.testing.assert_allclose(result.costs_per_hour, expected_costs)
|
||||
@@ -167,11 +144,25 @@ async def test_optimize(
|
||||
|
||||
# Check the correct generic optimization solution is created
|
||||
optimization_solution = await genetic_solution.optimization_solution()
|
||||
# @TODO
|
||||
dataframe = optimization_solution.solution.to_dataframe()
|
||||
assert len(dataframe) == expected_slots
|
||||
assert optimization_solution.valid_from == genetic_solution.start_solution_datetime
|
||||
assert optimization_solution.valid_until == ems_eos.start_datetime.add(hours=expected_slots)
|
||||
assert genetic_solution.controls_start_at_now
|
||||
assert len(genetic_solution.ac_charge) == expected_slots
|
||||
assert len(genetic_solution.dc_charge) == expected_slots
|
||||
assert len(genetic_solution.discharge_allowed) == expected_slots
|
||||
|
||||
# Check the correct generic energy management plan is created
|
||||
plan = genetic_solution.energy_management_plan()
|
||||
# @TODO
|
||||
assert plan.valid_from == optimization_solution.valid_from
|
||||
assert plan.valid_until is None
|
||||
assert optimization_solution.valid_from is not None
|
||||
assert optimization_solution.valid_until is not None
|
||||
assert all(
|
||||
optimization_solution.valid_from <= item.execution_time < optimization_solution.valid_until
|
||||
for item in plan.instructions
|
||||
)
|
||||
|
||||
# Check visualization works
|
||||
pdf = genetic_prepare_visualize(
|
||||
@@ -180,8 +171,4 @@ async def test_optimize(
|
||||
assert pdf.startswith(b"%PDF-")
|
||||
|
||||
reader = PdfReader(BytesIO(pdf))
|
||||
assert len(reader.pages) == 6
|
||||
|
||||
|
||||
# Everything passed, remove generated files
|
||||
TESTDATA_FILE.unlink()
|
||||
assert len(reader.pages) >= 6
|
||||
|
||||
@@ -12,6 +12,7 @@ import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
@@ -35,7 +36,8 @@ def prepare_tariffs(config_eos):
|
||||
"devices": {
|
||||
"max_batteries": 0,
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 0,
|
||||
"max_inverters": 1,
|
||||
"inverters": {"inverter1": {"max_power_w": 10000}},
|
||||
"max_home_appliances": 0,
|
||||
},
|
||||
}
|
||||
@@ -49,20 +51,27 @@ def prepare_tariffs(config_eos):
|
||||
"feed_in_tariff_wh": revenues,
|
||||
}
|
||||
|
||||
async def read_array(key, **kwargs):
|
||||
async def read_series(key, **kwargs):
|
||||
if key == "feed_in_tariff_wh" and tariff_reader is not None:
|
||||
return await tariff_reader(key=key, **kwargs)
|
||||
value = arrays[key]
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
return np.asarray(value)
|
||||
return pd.Series(
|
||||
value, index=pd.date_range("2026-08-01T00:00:00Z", periods=len(value), freq="h")
|
||||
)
|
||||
|
||||
prediction = Mock(update_data=AsyncMock(), key_to_array=AsyncMock(side_effect=read_array))
|
||||
ems = Mock(start_datetime=to_datetime("2026-08-01T00:00:00+00:00"))
|
||||
prediction = Mock(
|
||||
update_data=AsyncMock(), key_to_raw_series=AsyncMock(side_effect=read_series)
|
||||
)
|
||||
ems = Mock(
|
||||
start_datetime=to_datetime("2026-08-01T00:00:00+00:00", in_timezone="UTC"),
|
||||
observation_datetime=to_datetime("2026-08-01T00:00:00+00:00", in_timezone="UTC"),
|
||||
)
|
||||
ems.genetic_solution.return_value = None
|
||||
with (
|
||||
patch("akkudoktoreos.optimization.genetic.geneticparams.get_ems", return_value=ems),
|
||||
patch.object(GeneticOptimizationParameters, "prediction", prediction),
|
||||
patch("akkudoktoreos.optimization.genetic.configrequest.get_ems", return_value=ems),
|
||||
patch.object(ConfigOptimizationRequest, "prediction", prediction),
|
||||
):
|
||||
parameters = await GeneticOptimizationParameters.prepare()
|
||||
return parameters, prices, prediction
|
||||
@@ -100,7 +109,7 @@ async def test_provider_revenues_survive_preparation_and_simulation(
|
||||
# No battery or household load is needed to expose tariff substitution/unit bugs.
|
||||
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=10000))
|
||||
inverter.self_consumption_predictor = Mock()
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.return_value = 1.0
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.return_value = 0.0
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
parameters.ems, optimization_hours=24, prediction_hours=24, inverter=inverter
|
||||
@@ -121,7 +130,6 @@ async def test_provider_revenues_survive_preparation_and_simulation(
|
||||
RuntimeError("import unavailable"),
|
||||
[],
|
||||
[0.00007] * 23,
|
||||
[0.00007] * 25,
|
||||
[np.nan] * 24,
|
||||
[0.00007] * 23 + [np.nan],
|
||||
[np.inf] * 24,
|
||||
@@ -152,14 +160,14 @@ def test_prediction_record_prices_are_already_per_wh():
|
||||
async def test_timestamped_import_records_are_read_in_order(prepare_tariffs, values):
|
||||
provider = FeedInTariffImport()
|
||||
provider._db_reset_state()
|
||||
start = to_datetime("2026-08-01T00:00:00+00:00").set(hour=0)
|
||||
start = to_datetime("2026-08-01T00:00:00+00:00", in_timezone="UTC").set(hour=0)
|
||||
try:
|
||||
await provider.key_from_series(
|
||||
"feed_in_tariff_wh",
|
||||
pd.Series(values, index=pd.date_range(start=start, periods=24, freq="h")),
|
||||
)
|
||||
parameters, _, _ = await prepare_tariffs(
|
||||
"FeedInTariffImport", [], tariff_reader=provider.key_to_array
|
||||
"FeedInTariffImport", [], tariff_reader=provider.key_to_raw_series
|
||||
)
|
||||
if values[0] is None:
|
||||
assert parameters is None
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Semantic report tests with synthetic data; no forecast provider or live EMS run."""
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import matplotlib.dates as mdates
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pendulum
|
||||
import pytest
|
||||
from pypdf import PdfReader
|
||||
|
||||
from akkudoktoreos.optimization.genetic import geneticvisualize as visualize
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import (
|
||||
TailDiagnostics,
|
||||
TailPlanSlot,
|
||||
TerminalValueCurve,
|
||||
TerminalValueResult,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def report_solution():
|
||||
start = pendulum.datetime(2026, 10, 25, 1, 45, tz="Europe/Berlin")
|
||||
energy = [100.0, 150.0, 200.0, 250.0]
|
||||
result = SimpleNamespace(
|
||||
load_wh_per_hour=energy,
|
||||
home_appliance_wh_per_hour=energy,
|
||||
grid_feed_in_wh_per_hour=energy,
|
||||
grid_consumption_wh_per_hour=energy,
|
||||
losses_per_hour=[1.0] * 4,
|
||||
battery_soc_per_hour=[20.0, 25.0, 30.0, 35.0],
|
||||
ev_soc_per_hour=[0.0] * 4,
|
||||
costs_per_hour=[0.03] * 4,
|
||||
revenue_per_hour=[0.01] * 4,
|
||||
total_costs=0.12,
|
||||
total_revenue=0.04,
|
||||
total_balance=0.08,
|
||||
home_appliance_energy_wh={"water-heater": energy},
|
||||
)
|
||||
return SimpleNamespace(
|
||||
parameters=SimpleNamespace(
|
||||
ems=SimpleNamespace(
|
||||
total_load=energy,
|
||||
pv_forecast_wh=energy,
|
||||
feed_in_tariff_per_wh=[0.00007] * 4,
|
||||
electricity_price_per_wh=[0.0003] * 4,
|
||||
),
|
||||
temperature_forecast=[12.0] * 4,
|
||||
),
|
||||
interval_seconds=900,
|
||||
start_solution_datetime=start,
|
||||
controls_start_at_now=True,
|
||||
start_hour=1,
|
||||
ac_charge=[0.0, 0.25, 0.5, 1.0],
|
||||
dc_charge=[1.0] * 4,
|
||||
discharge_allowed=[0, 0, 1, 1],
|
||||
battery_grid_export_allowed=[0, 0, 0, 1],
|
||||
battery_grid_export_factor=[0.0, 0.0, 0.0, 0.5],
|
||||
result=result,
|
||||
extra_data=None,
|
||||
fitness_history=None,
|
||||
fixed_seed=None,
|
||||
appliance_starts={"water-heater": [start.add(minutes=15)]},
|
||||
appliance_deadline_missed={"water-heater": True},
|
||||
terminal_value=TerminalValueResult(
|
||||
mode="TAIL", control_horizon_hours=1, requested_tail_hours=2,
|
||||
effective_tail_hours=0.25, tail_end_hour=1.25,
|
||||
battery_energy_wh=500, credited_euro=0.13,
|
||||
tail_operating_euro=0.1, continuation_value_euro=0.03,
|
||||
continuation_mode="AUTO", reason="forecast gap limits tail",
|
||||
curve=TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.26]),
|
||||
tail_diagnostics=TailDiagnostics(slots=1, slot_hours=0.25),
|
||||
tail_plan=[TailPlanSlot(
|
||||
slot=0, hour_from_start=1, action="discharge",
|
||||
soc_start_percentage=40, soc_end_percentage=35,
|
||||
pv_wh=0, load_wh=100, grid_import_wh=0, grid_export_wh=0,
|
||||
battery_charge_wh=0, battery_discharge_wh=100,
|
||||
import_price_euro_per_kwh=0.3, feed_in_tariff_euro_per_kwh=0.07,
|
||||
slot_value_euro=0.03, remaining_value_euro=0.1,
|
||||
ac_charge_factor=0, dc_charge_allowed=1, discharge_allowed=1,
|
||||
battery_grid_export_factor=0,
|
||||
)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interval_seconds", [900, 3600])
|
||||
def test_report_preserves_run_timing_energy_and_export_controls(
|
||||
config_eos, monkeypatch, report_solution, interval_seconds
|
||||
):
|
||||
report_solution.interval_seconds = interval_seconds
|
||||
captured = []
|
||||
original = visualize.GeneticVisualizationReport.create_line_chart_date
|
||||
|
||||
def capture(self, start_date, y_list, **kwargs):
|
||||
captured.append((start_date, y_list, kwargs, self.interval_seconds))
|
||||
return original(self, start_date, y_list, **kwargs)
|
||||
|
||||
monkeypatch.setattr(visualize.GeneticVisualizationReport, "create_line_chart_date", capture)
|
||||
monkeypatch.setattr(visualize, "get_ems", lambda: pytest.fail("Snapshot must own report timing"))
|
||||
pdf = visualize.genetic_prepare_visualize(report_solution)
|
||||
assert pdf.startswith(b"%PDF-")
|
||||
assert all(row[0] == report_solution.start_solution_datetime for row in captured)
|
||||
assert all(row[3] == interval_seconds for row in captured)
|
||||
load = next(row for row in captured if row[2].get("title") == "Load Profile")
|
||||
assert load[1] == [[100, 150, 200, 250]] # No repeated wall-clock trimming or Wh scaling.
|
||||
controls = next(row for row in captured if row[2].get("title") == "Executable Battery Controls")
|
||||
assert controls[1][-1] == [0, 0, 0, 0.5]
|
||||
assert len(controls[1]) == 5
|
||||
text = " ".join(page.extract_text() for page in PdfReader(BytesIO(pdf)).pages)
|
||||
for expected in (
|
||||
"Energy Flow per Interval", "water-heater", "Deadline missed: True",
|
||||
"forecast gap limits tail", "effective tail: 0.25 h", "0.13 EUR",
|
||||
"not executable controls", "not realized revenue", "Residual Battery Value Curve",
|
||||
):
|
||||
assert expected in text
|
||||
assert "Energy Flow per Hour" not in text
|
||||
|
||||
|
||||
def test_short_quarterhour_chart_has_elapsed_fractional_hours(config_eos):
|
||||
report = visualize.GeneticVisualizationReport(interval_seconds=900)
|
||||
start = pendulum.datetime(2026, 10, 25, 2, 45, tz="Europe/Berlin", fold=0)
|
||||
report.create_line_chart_date(start, [[100.0, 200.0, 300.0, 400.0]], ylabel="Wh")
|
||||
fig, axis = plt.subplots()
|
||||
try:
|
||||
report.current_group[0]()
|
||||
dates = mdates.num2date(axis.lines[0].get_xdata())
|
||||
assert np.diff([value.timestamp() for value in dates]).tolist() == [900] * 3
|
||||
assert np.asarray(axis.lines[0].get_ydata()).tolist() == [100, 200, 300, 400]
|
||||
assert [label.get_text() for label in fig.axes[1].get_xticklabels()] == ["0", "0.25", "0.5", "0.75"]
|
||||
finally:
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_and_mismatched_date_series(config_eos):
|
||||
report = visualize.GeneticVisualizationReport()
|
||||
start = pendulum.now("UTC")
|
||||
report.create_line_chart_date(start, [[]], ylabel="Wh")
|
||||
assert report.current_group == []
|
||||
with pytest.raises(ValueError, match="same intervals"):
|
||||
report.create_line_chart_date(start, [[1.0, 2.0], [1.0]], ylabel="Wh")
|
||||
|
||||
|
||||
def test_existing_hourly_solution_renders_without_terminal_metadata(config_eos, monkeypatch):
|
||||
payload = (Path(__file__).parent / "testdata/genetic/optimize_result_1.json").read_text()
|
||||
solution = GeneticSolution.model_validate_json(payload)
|
||||
assert not solution.controls_start_at_now
|
||||
assert solution.terminal_value is None
|
||||
start = pendulum.datetime(2026, 9, 16, solution.start_hour, tz="UTC")
|
||||
monkeypatch.setattr(visualize, "get_ems", lambda: SimpleNamespace(start_datetime=start))
|
||||
pdf = visualize.genetic_prepare_visualize(solution)
|
||||
assert len(PdfReader(BytesIO(pdf)).pages) >= 4
|
||||
|
||||
|
||||
def test_fixed_fallback_does_not_invent_tail_charts(config_eos, report_solution):
|
||||
report_solution.terminal_value = TerminalValueResult(
|
||||
mode="FIXED", reason="No complete tail forecast", credited_euro=0.05,
|
||||
battery_energy_wh=500, requested_tail_hours=24,
|
||||
)
|
||||
report_solution.result.home_appliance_energy_wh = {}
|
||||
pdf = visualize.genetic_prepare_visualize(report_solution)
|
||||
text = " ".join(page.extract_text() for page in PdfReader(BytesIO(pdf)).pages)
|
||||
assert "No complete tail forecast" in text
|
||||
assert "effective tail: 0 h" in text
|
||||
assert "Deadline missed: True" in text # Report unscheduled consumers even with no energy series.
|
||||
assert "Residual Battery Value Curve" not in text
|
||||
assert "Tail Lookahead:" not in text
|
||||
|
||||
|
||||
def test_tail_chart_starts_after_control_horizon(config_eos, report_solution):
|
||||
report = visualize.GeneticVisualizationReport(interval_seconds=900)
|
||||
start = report_solution.start_solution_datetime
|
||||
visualize._add_solution_diagnostics(report, report_solution, start)
|
||||
fig, axis = plt.subplots()
|
||||
try:
|
||||
report.groups[-1][0]()
|
||||
expected = mdates.date2num(start.add(hours=1))
|
||||
assert np.asarray(axis.lines[0].get_xdata()).tolist() == [expected]
|
||||
assert np.asarray(axis.lines[3].get_ydata()).tolist() == [100]
|
||||
assert all(line.get_marker() == "o" for line in axis.lines)
|
||||
left, right = axis.get_xlim()
|
||||
assert left < expected < right
|
||||
assert (right - left) * 86400 == pytest.approx(900)
|
||||
assert "Not Executable" in axis.get_title()
|
||||
assert report_solution.discharge_allowed == [0, 0, 1, 1]
|
||||
finally:
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tariff", [0.00007, [0.00007] * 6])
|
||||
def test_pdf_clips_forecast_tail_and_accepts_historical_diagnostics(
|
||||
config_eos, monkeypatch, report_solution, tariff
|
||||
):
|
||||
report_solution.parameters.ems.total_load = [100.0] * 6
|
||||
report_solution.parameters.ems.pv_forecast_wh = [200.0] * 6
|
||||
report_solution.parameters.ems.feed_in_tariff_per_wh = tariff
|
||||
report_solution.extra_data = {
|
||||
"verluste": [1.0, 2.0, 3.0], "bilanz": [0.1, 0.2, 0.3],
|
||||
"nebenbedingung": [0.0, 0.0, 0.0],
|
||||
}
|
||||
captured = []
|
||||
original = visualize.GeneticVisualizationReport.create_line_chart_date
|
||||
|
||||
def capture(self, start_date, y_list, **kwargs):
|
||||
captured.append((y_list, kwargs))
|
||||
return original(self, start_date, y_list, **kwargs)
|
||||
|
||||
monkeypatch.setattr(visualize.GeneticVisualizationReport, "create_line_chart_date", capture)
|
||||
pdf = visualize.genetic_prepare_visualize(report_solution)
|
||||
assert pdf.startswith(b"%PDF-")
|
||||
tariff_plot = next(series for series, kwargs in captured if kwargs.get("title") == "Remuneration")
|
||||
assert np.asarray(tariff_plot[0]).tolist() == [0.00007] * 4
|
||||
assert all(len(series[0]) == 4 for series, _ in captured)
|
||||
assert "verluste" in report_solution.extra_data # Rendering must not mutate the retained run.
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Contracts shared by the configuration, tariff and optimizer PRs."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.optimization.genetic0.genetic0params import (
|
||||
Genetic0EnergyManagementParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model", [GeneticEnergyManagementParameters, Genetic0EnergyManagementParameters]
|
||||
)
|
||||
@pytest.mark.parametrize("tariff", [0.00008, [0.00008, -0.00002]])
|
||||
def test_energy_parameter_aliases_preserve_wh_prices(model, tariff):
|
||||
raw = {
|
||||
"pv_prognose_wh": [250.0, 0.0],
|
||||
"gesamtlast": [125.0, 125.0],
|
||||
"strompreis_euro_pro_wh": [0.0003, -0.0001],
|
||||
"einspeiseverguetung_euro_pro_wh": tariff,
|
||||
"preis_euro_pro_wh_akku": 0.00005,
|
||||
}
|
||||
params = model.model_validate(raw)
|
||||
data = params.model_dump(mode="json")
|
||||
pairs = {
|
||||
"pv_prognose_wh": "pv_forecast_wh",
|
||||
"gesamtlast": "total_load",
|
||||
"strompreis_euro_pro_wh": "electricity_price_per_wh",
|
||||
"einspeiseverguetung_euro_pro_wh": "feed_in_tariff_per_wh",
|
||||
"preis_euro_pro_wh_akku": "price_per_wh_battery",
|
||||
}
|
||||
for deprecated, canonical in pairs.items():
|
||||
assert data[deprecated] == data[canonical] == raw[deprecated]
|
||||
assert (
|
||||
model.model_validate({pairs[k]: v for k, v in raw.items()}).model_dump(mode="json") == data
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("algorithm", list(OptimizationAlgorithm))
|
||||
async def test_algorithm_result_endpoint_keeps_solution_types_separate(monkeypatch, algorithm):
|
||||
from akkudoktoreos.server import eos
|
||||
|
||||
genetic, genetic0 = object(), object()
|
||||
fake = SimpleNamespace(
|
||||
genetic_solution=Mock(return_value=genetic),
|
||||
genetic0_solution=Mock(return_value=genetic0),
|
||||
)
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: fake)
|
||||
monkeypatch.setattr(
|
||||
eos,
|
||||
"get_config",
|
||||
lambda: SimpleNamespace(optimization=SimpleNamespace(algorithms=["GENETIC", "GENETIC0"])),
|
||||
)
|
||||
result = await eos.fastapi_energy_management_optimization_solution_algorithm_get(algorithm)
|
||||
if algorithm == OptimizationAlgorithm.GENETIC:
|
||||
assert result is genetic
|
||||
fake.genetic_solution.assert_called_once_with()
|
||||
fake.genetic0_solution.assert_not_called()
|
||||
else:
|
||||
assert result is genetic0
|
||||
fake.genetic0_solution.assert_called_once_with()
|
||||
fake.genetic_solution.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("algorithm", ["genetic", "genetic0"])
|
||||
def test_device_maps_feed_algorithm_specific_converters(algorithm):
|
||||
from akkudoktoreos.devices.devices import DevicesCommonSettings
|
||||
|
||||
settings = DevicesCommonSettings.model_validate(
|
||||
{
|
||||
"batteries": {"storage": {"capacity_wh": 12000, "max_charge_power_w": 3200}},
|
||||
"electric_vehicles": {"car": {"capacity_wh": 60000, "max_charge_power_w": 7000}},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 5000}},
|
||||
"home_appliances": {
|
||||
"washer": {
|
||||
"consumption_wh": 1800,
|
||||
"duration_h": 2,
|
||||
"num_cycles": 2,
|
||||
"min_cycle_gap_h": 1,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert settings.batteries is not None
|
||||
assert settings.electric_vehicles is not None
|
||||
assert settings.inverters is not None
|
||||
battery = getattr(settings.batteries["storage"], "to_" + algorithm + "_pv_bat_param")()
|
||||
ev = getattr(settings.electric_vehicles["car"], "to_" + algorithm + "_ev_bat_param")()
|
||||
inverter = getattr(settings.inverters["inverter"], "to_" + algorithm + "_param")()
|
||||
appliance = getattr(settings.home_appliances["washer"], "to_" + algorithm + "_param")()
|
||||
assert (battery.device_id, ev.device_id, inverter.device_id, appliance.device_id) == (
|
||||
"storage",
|
||||
"car",
|
||||
"inverter",
|
||||
"washer",
|
||||
)
|
||||
assert battery.capacity_wh == 12000
|
||||
assert battery.max_charge_power_w == 3200
|
||||
assert ev.capacity_wh == 60000
|
||||
assert ev.max_charge_power_w == 7000
|
||||
assert inverter.max_power_wh == 5000
|
||||
assert inverter.battery_id == "storage"
|
||||
assert appliance.consumption_wh == 1800
|
||||
assert appliance.duration_h == 2
|
||||
assert settings.batteries["storage"].measurement_key_soc_factor == "storage-soc-factor"
|
||||
if algorithm == "genetic":
|
||||
assert appliance.num_cycles == 2
|
||||
assert appliance.min_cycle_gap_h == 1
|
||||
@@ -0,0 +1,78 @@
|
||||
"""The report endpoint selects retained results and renders outside the event loop."""
|
||||
|
||||
import threading
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pypdf import PdfReader
|
||||
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.server import eos
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retained_result():
|
||||
path = Path(__file__).parent / "testdata/genetic/optimize_result_1.json"
|
||||
solution = GeneticSolution.model_validate_json(path.read_text())
|
||||
solution.start_solution_datetime = to_datetime("2026-09-16T10:00:00+02:00")
|
||||
return solution
|
||||
|
||||
|
||||
def test_missing_algorithm_report_returns_404(config_eos, monkeypatch):
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(genetic_solution=lambda: None))
|
||||
response = TestClient(eos.app).get("/v1/energy-management/optimization/solution/GENETIC/pdf")
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
|
||||
|
||||
def test_retained_algorithm_result_renders_real_pdf(config_eos, monkeypatch, retained_result):
|
||||
solution = retained_result
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(genetic_solution=lambda: solution))
|
||||
# A later run and changed configuration must not retime the retained report.
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic import geneticvisualize
|
||||
|
||||
get_ems().set_start_datetime(to_datetime("2027-01-01T00:00:00+01:00"))
|
||||
config_eos.optimization.genetic.interval_sec = 900
|
||||
monkeypatch.setattr(
|
||||
geneticvisualize, "get_ems", lambda: pytest.fail("Report must retain its own time grid")
|
||||
)
|
||||
before = solution.model_dump_json()
|
||||
response = TestClient(eos.app).get("/v1/energy-management/optimization/solution/GENETIC/pdf")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
assert "genetic" in response.headers["content-disposition"]
|
||||
assert response.content.startswith(b"%PDF-")
|
||||
assert len(PdfReader(BytesIO(response.content)).pages) >= 4
|
||||
assert solution.model_dump_json() == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_is_offloaded_and_uses_an_owned_copy(config_eos, monkeypatch, retained_result):
|
||||
solution = retained_result
|
||||
caller_thread = threading.get_ident()
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
eos,
|
||||
"get_ems",
|
||||
lambda: SimpleNamespace(
|
||||
genetic_solution=lambda: solution, genetic0_solution=lambda: solution
|
||||
),
|
||||
)
|
||||
original_controls = list(solution.ac_charge)
|
||||
|
||||
def render(*, solution):
|
||||
captured.append((threading.get_ident(), solution))
|
||||
solution.ac_charge[0] = 0.123
|
||||
return b"%PDF-isolated-render"
|
||||
|
||||
monkeypatch.setattr(eos, "genetic_prepare_visualize", render)
|
||||
response = await eos.fastapi_energy_management_optimization_solution_genetic_pdf_get()
|
||||
assert response.body == b"%PDF-isolated-render"
|
||||
assert captured[0][0] != caller_thread
|
||||
assert captured[0][1] is not solution
|
||||
assert solution.ac_charge == original_controls
|
||||
@@ -22,6 +22,7 @@ def offline_ems(monkeypatch):
|
||||
cls = ems_module.EnergyManagement
|
||||
for name in (
|
||||
"_start_datetime",
|
||||
"_observation_datetime",
|
||||
"_last_run_datetime",
|
||||
"_plan",
|
||||
"_optimization_solution",
|
||||
@@ -34,10 +35,11 @@ def offline_ems(monkeypatch):
|
||||
monkeypatch.setattr(ems_module, "CacheEnergyManagementStore", Mock())
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
general=SimpleNamespace(timezone="Europe/Berlin"),
|
||||
ems=SimpleNamespace(mode=EnergyManagementMode.OPTIMIZATION),
|
||||
optimization=SimpleNamespace(
|
||||
algorithm=OptimizationAlgorithm.GENETIC,
|
||||
genetic=SimpleNamespace(generations=3, seed=17),
|
||||
genetic=SimpleNamespace(generations=3, seed=17, interval_sec=3600, individuals=31),
|
||||
genetic0=SimpleNamespace(generations=5, seed=29),
|
||||
),
|
||||
server=SimpleNamespace(verbose=False),
|
||||
@@ -48,6 +50,42 @@ def offline_ems(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("algorithm", list(OptimizationAlgorithm))
|
||||
@pytest.mark.parametrize("explicit_start", [False, True])
|
||||
async def test_implicit_genetic_start_uses_site_timezone(
|
||||
offline_ems, monkeypatch, set_other_timezone, algorithm, explicit_start
|
||||
):
|
||||
set_other_timezone("UTC")
|
||||
now = to_datetime("2026-09-16T22:47:23Z", in_timezone="UTC")
|
||||
|
||||
def frozen_datetime(value=None, **kwargs):
|
||||
return to_datetime(now if value is None else value, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ems_module, "to_datetime", frozen_datetime)
|
||||
offline_ems.config.optimization.genetic.interval_sec = 900
|
||||
await ems_module.EnergyManagement.run(
|
||||
offline_ems,
|
||||
mode=EnergyManagementMode.PREDICTION,
|
||||
algorithm=algorithm,
|
||||
start_datetime=now if explicit_start else None,
|
||||
)
|
||||
start = ems_module.EnergyManagement._start_datetime
|
||||
observed = ems_module.EnergyManagement._observation_datetime
|
||||
assert start is not None and observed is not None
|
||||
assert observed.timestamp() == now.timestamp()
|
||||
if algorithm == OptimizationAlgorithm.GENETIC:
|
||||
assert start.minute == 45
|
||||
assert start.hour == (22 if explicit_start else 0)
|
||||
assert start.day == (16 if explicit_start else 17)
|
||||
assert start.timezone_name == ("UTC" if explicit_start else "Europe/Berlin")
|
||||
else:
|
||||
assert start.minute == 0
|
||||
assert start.hour == 22
|
||||
assert start.day == 16
|
||||
assert start.timezone_name == "UTC"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("algorithm", list(OptimizationAlgorithm))
|
||||
@pytest.mark.parametrize("selection", ["configured", "explicit"])
|
||||
@@ -90,6 +128,8 @@ async def test_optimization_routes_only_selected_algorithm(
|
||||
kwargs[suffix + "_parameters"] = sentinel_parameters
|
||||
kwargs[suffix + "_generations"] = 7
|
||||
kwargs[suffix + "_seed"] = 43
|
||||
if algorithm == OptimizationAlgorithm.GENETIC:
|
||||
kwargs["genetic_individuals"] = 11
|
||||
run_result = await ems_module.EnergyManagement.run(offline_ems, **kwargs)
|
||||
assert run_result is solution
|
||||
selected = constructors[selected_name]
|
||||
@@ -97,11 +137,14 @@ async def test_optimization_routes_only_selected_algorithm(
|
||||
selected.assert_called_once_with(
|
||||
verbose=False, fixed_seed=43 if supplied else expected_config.seed
|
||||
)
|
||||
selected.return_value.optimize_ems.assert_called_once_with(
|
||||
start_hour=expected_hour,
|
||||
parameters=sentinel_parameters,
|
||||
ngen=7 if supplied else expected_config.generations,
|
||||
)
|
||||
expected_arguments: dict[str, Any] = {
|
||||
"start_hour": expected_hour,
|
||||
"parameters": sentinel_parameters,
|
||||
"ngen": 7 if supplied else expected_config.generations,
|
||||
}
|
||||
if algorithm == OptimizationAlgorithm.GENETIC:
|
||||
expected_arguments["individuals"] = 11 if supplied else None
|
||||
selected.return_value.optimize_ems.assert_called_once_with(**expected_arguments)
|
||||
other_name = "Genetic0" if selected_name == "Genetic" else "Genetic"
|
||||
constructors[other_name].assert_not_called()
|
||||
preparers[other_name].assert_not_awaited()
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Economic tail scenarios and hard control/forecast boundaries."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import SettingsEOSDefaults
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.tailvalue import build_tail_value_curve
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueCurve
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
def devices(power=1000, efficiency=1.0, lcos=0, ac_limit=None, export_power=5000):
|
||||
bat = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=1000,
|
||||
max_charge_power_w=power,
|
||||
charging_efficiency=efficiency,
|
||||
discharging_efficiency=efficiency,
|
||||
initial_soc_percentage=50,
|
||||
levelized_cost_of_storage_kwh=lcos,
|
||||
charge_rates=[0, 0.5, 1],
|
||||
),
|
||||
prediction_hours=1,
|
||||
)
|
||||
inv = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
battery_id="battery1",
|
||||
max_power_wh=export_power,
|
||||
dc_to_ac_efficiency=1,
|
||||
ac_to_dc_efficiency=1,
|
||||
max_ac_charge_power_w=ac_limit,
|
||||
),
|
||||
battery=bat,
|
||||
)
|
||||
return bat, inv
|
||||
|
||||
|
||||
def curve(
|
||||
prices=(-0.1, 0.3),
|
||||
tariffs=(0, 0.3),
|
||||
direct=True,
|
||||
continuation=None,
|
||||
load=None,
|
||||
pv=None,
|
||||
**kwargs,
|
||||
):
|
||||
bat, inv = devices(**kwargs)
|
||||
return build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array(prices) / 1000,
|
||||
feed_in_euro_per_wh=np.array(tariffs) / 1000,
|
||||
load_wh=np.zeros(len(prices)) if load is None else np.array(load),
|
||||
pv_wh=np.zeros(len(prices)) if pv is None else np.array(pv),
|
||||
continuation=continuation or TerminalValueCurve(),
|
||||
charge_rates=[0.5, 1],
|
||||
export_rates=[1],
|
||||
direct_marketing=direct,
|
||||
)
|
||||
|
||||
|
||||
def test_headroom_has_value_and_empty_state_can_earn():
|
||||
c = curve()
|
||||
assert c.value(0) == pytest.approx(0.4)
|
||||
tail, continuation = c.component_values(0)
|
||||
assert tail == pytest.approx(0.4)
|
||||
assert continuation == pytest.approx(0.0)
|
||||
assert c.value(0) == pytest.approx(tail + continuation)
|
||||
assert c.value(500) > c.value(1000)
|
||||
assert any(v < 0 for v in c.marginal_euro_per_kwh)
|
||||
|
||||
|
||||
def test_chronology_changes_arbitrage():
|
||||
forward = curve()
|
||||
reverse = curve(prices=(0.3, -0.1), tariffs=(0.3, 0))
|
||||
assert forward.value(0) > reverse.value(0)
|
||||
|
||||
|
||||
def test_discharge_and_ac_power_limits():
|
||||
limited = curve(prices=(1,), tariffs=(1,), power=100)
|
||||
assert limited.value(1000) == pytest.approx(0.1)
|
||||
limited_ac = curve(ac_limit=100)
|
||||
assert limited_ac.value(0) == pytest.approx(0.04)
|
||||
limited_inverter = curve(prices=(1,), tariffs=(1,), export_power=50)
|
||||
assert limited_inverter.value(1000) == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_losses_and_lcos_reduce_arbitrage():
|
||||
ideal = curve(prices=(0.1, 0.3))
|
||||
lossy = curve(prices=(0.1, 0.3), efficiency=0.8)
|
||||
assert 0 < lossy.value(0) < ideal.value(0)
|
||||
assert curve(prices=(0.1, 0.3), lcos=0.25).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_no_battery_export_without_permission():
|
||||
assert curve(prices=(0.1, 0.3), direct=False).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_pv_surplus_can_be_stored_for_local_load():
|
||||
c = curve(prices=(0.2, 0.3), tariffs=(0, 0), pv=[1000, 0], load=[0, 1000], direct=False)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
# Without PV the same empty battery must buy energy to serve the load.
|
||||
assert curve(prices=(0.2, 0.3), tariffs=(0, 0), load=[0, 1000], direct=False).value(
|
||||
0
|
||||
) < c.value(0)
|
||||
|
||||
|
||||
def test_continuation_survives_tail_end():
|
||||
continuation = TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.2])
|
||||
c = curve(prices=(0.5,), tariffs=(0,), continuation=continuation)
|
||||
assert c.value(1000) == pytest.approx(0.2)
|
||||
tail, continuation_credit = c.component_values(1000)
|
||||
assert tail == pytest.approx(0.0)
|
||||
assert continuation_credit == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_tail_diagnostic_plan_explains_the_selected_path():
|
||||
c = curve()
|
||||
plan = c.diagnostic_plan(0, control_horizon_hours=24)
|
||||
assert len(plan) == 2
|
||||
assert plan[0].hour_from_start == 24
|
||||
assert plan[0].action == "GRID_CHARGE"
|
||||
assert plan[0].soc_end_percentage > plan[0].soc_start_percentage
|
||||
assert plan[0].grid_import_wh > 0
|
||||
assert plan[1].action == "BATTERY_EXPORT"
|
||||
assert plan[1].soc_end_percentage < plan[1].soc_start_percentage
|
||||
assert plan[1].grid_export_wh > 0
|
||||
assert sum(slot.slot_value_euro for slot in plan) == pytest.approx(c.value(0))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_values_are_not_extrapolated():
|
||||
from types import SimpleNamespace
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
series = pd.Series([1.0, 2.0], index=pd.date_range(start=start, periods=2, freq="h"))
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
provider = SimpleNamespace(key_to_raw_series=AsyncMock(return_value=series))
|
||||
result = await bounded_forecast_array(
|
||||
provider,
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=3),
|
||||
interval=to_duration("15 minutes"),
|
||||
)
|
||||
assert result[:8].tolist() == [1.0] * 4 + [2.0] * 4
|
||||
assert np.isnan(result[8:]).all()
|
||||
|
||||
|
||||
def test_disabled_ac_conversion_cannot_earn_negative_price_revenue():
|
||||
bat, inv = devices()
|
||||
inv.parameters.ac_to_dc_efficiency = 0
|
||||
c = build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array([-0.001, 0.001]),
|
||||
feed_in_euro_per_wh=np.array([0.0, 0.001]),
|
||||
load_wh=np.zeros(2),
|
||||
pv_wh=np.zeros(2),
|
||||
continuation=TerminalValueCurve(),
|
||||
charge_rates=[1],
|
||||
export_rates=[1],
|
||||
direct_marketing=True,
|
||||
)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
assert bat.soc_wh == 500 # Building the tail never mutates the real battery.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_provider_key_stays_missing():
|
||||
from types import SimpleNamespace
|
||||
|
||||
async def unavailable(*a, **kw):
|
||||
raise KeyError("price unavailable")
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
result = await bounded_forecast_array(
|
||||
SimpleNamespace(key_to_raw_series=unavailable),
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=2),
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
assert np.isnan(result).all()
|
||||
assert len(result) == 2
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for the concave terminal value of the energy left in the battery."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import (
|
||||
build_terminal_value_curve,
|
||||
trailing_window,
|
||||
)
|
||||
|
||||
|
||||
def _curve(**overrides):
|
||||
"""Two expensive slots, one cheap one, no PV, 10 kWh of usable battery."""
|
||||
params: dict[str, Any] = dict(
|
||||
prices_euro_per_wh=np.array([0.0004, 0.0003, 0.0001]),
|
||||
load_wh=np.array([1000.0, 1000.0, 1000.0]),
|
||||
pv_wh=np.array([0.0, 0.0, 0.0]),
|
||||
feed_in_euro_per_wh=np.array([0.00008, 0.00008, 0.00008]),
|
||||
max_energy_wh=10000.0,
|
||||
lcos_euro_per_kwh=0.0,
|
||||
dc_to_ac_efficiency=1.0,
|
||||
grid_export_allowed=False,
|
||||
)
|
||||
params.update(overrides)
|
||||
return build_terminal_value_curve(**params)
|
||||
|
||||
|
||||
def test_marginal_value_follows_the_most_expensive_hours_first():
|
||||
"""The first stored kWh replaces the most expensive slot, then the next."""
|
||||
curve = _curve()
|
||||
|
||||
# 0.40, 0.30 and 0.10 EUR/kWh, in that order.
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4, 0.3, 0.1])
|
||||
assert curve.energy_wh == pytest.approx([0.0, 1000.0, 2000.0, 3000.0])
|
||||
assert curve.value_euro == pytest.approx([0.0, 0.4, 0.7, 0.8])
|
||||
|
||||
|
||||
def test_curve_is_concave_and_saturates():
|
||||
"""Marginal values only decrease, and beyond the last breakpoint nothing is added."""
|
||||
curve = _curve()
|
||||
marginals = curve.marginal_euro_per_kwh
|
||||
|
||||
assert all(a >= b for a, b in zip(marginals, marginals[1:]))
|
||||
# The residual load of the window is 3 kWh - more energy replaces nothing.
|
||||
assert curve.value(3000.0) == pytest.approx(0.8)
|
||||
assert curve.value(9000.0) == pytest.approx(0.8)
|
||||
|
||||
|
||||
def test_value_interpolates_within_a_segment():
|
||||
"""Half of the first slot is worth half of the first segment."""
|
||||
curve = _curve()
|
||||
assert curve.value(500.0) == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_pv_reduces_the_residual_load():
|
||||
"""Only load that PV cannot cover can be replaced by stored energy."""
|
||||
curve = _curve(pv_wh=np.array([600.0, 1000.0, 0.0]))
|
||||
|
||||
# Slot 0 keeps 400 Wh, slot 1 is fully covered by PV, slot 2 keeps 1000 Wh.
|
||||
assert curve.energy_wh == pytest.approx([0.0, 400.0, 1400.0])
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4, 0.1])
|
||||
|
||||
|
||||
def test_lcos_is_subtracted_from_the_marginal_value():
|
||||
"""Storage cost is already charged on discharge and must not be credited twice."""
|
||||
curve = _curve(lcos_euro_per_kwh=0.05, dc_to_ac_efficiency=1.0)
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.35, 0.25, 0.05])
|
||||
|
||||
|
||||
def test_negative_prices_do_not_create_value():
|
||||
"""Storing energy for an hour that pays nothing is not worth anything."""
|
||||
curve = _curve(prices_euro_per_wh=np.array([0.0004, -0.0001, 0.0]))
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4])
|
||||
assert curve.value(5000.0) == pytest.approx(0.4)
|
||||
|
||||
|
||||
def test_export_tail_only_with_direct_marketing():
|
||||
"""Surplus beyond the residual load is worth an export - if export is allowed."""
|
||||
without = _curve(grid_export_allowed=False)
|
||||
with_export = _curve(grid_export_allowed=True)
|
||||
|
||||
assert without.value(10000.0) == pytest.approx(0.8)
|
||||
# 7 kWh beyond the residual load at the median feed-in tariff of 0.08 EUR/kWh.
|
||||
assert with_export.value(10000.0) == pytest.approx(0.8 + 7.0 * 0.08)
|
||||
assert with_export.marginal_euro_per_kwh[-1] == pytest.approx(0.08)
|
||||
|
||||
|
||||
def test_residual_energy_marks_the_knee():
|
||||
"""The knee separates load-backed value from the export tail."""
|
||||
without = _curve(grid_export_allowed=False)
|
||||
with_export = _curve(grid_export_allowed=True)
|
||||
|
||||
# 3 kWh of residual load in the window, whether or not export is allowed.
|
||||
assert without.residual_energy_wh == pytest.approx(3000.0)
|
||||
assert with_export.residual_energy_wh == pytest.approx(3000.0)
|
||||
# Only the export tail reaches beyond it.
|
||||
assert without.energy_wh[-1] == pytest.approx(3000.0)
|
||||
assert with_export.energy_wh[-1] == pytest.approx(10000.0)
|
||||
|
||||
|
||||
def test_curve_is_capped_by_the_usable_battery_energy():
|
||||
"""A battery smaller than the residual load ends the curve early."""
|
||||
curve = _curve(max_energy_wh=1500.0)
|
||||
assert curve.energy_wh[-1] == pytest.approx(1500.0)
|
||||
assert curve.value(5000.0) == pytest.approx(0.4 + 0.5 * 0.3)
|
||||
|
||||
|
||||
def test_empty_window_yields_an_empty_curve():
|
||||
"""Without data there is no curve, and no credit."""
|
||||
curve = build_terminal_value_curve(
|
||||
prices_euro_per_wh=np.zeros(0),
|
||||
load_wh=np.zeros(0),
|
||||
pv_wh=np.zeros(0),
|
||||
feed_in_euro_per_wh=np.zeros(0),
|
||||
max_energy_wh=10000.0,
|
||||
)
|
||||
assert curve.energy_wh == []
|
||||
assert curve.value(5000.0) == 0.0
|
||||
|
||||
|
||||
def test_trailing_window_takes_the_end_of_the_horizon():
|
||||
values = np.arange(10, dtype=float)
|
||||
|
||||
assert list(trailing_window(values, end_slot=8, window_slots=3)) == [5.0, 6.0, 7.0]
|
||||
# A window longer than the horizon yields what there is.
|
||||
assert list(trailing_window(values, end_slot=2, window_slots=5)) == [0.0, 1.0]
|
||||
assert list(trailing_window(None, end_slot=8, window_slots=3)) == []
|
||||
@@ -130,11 +130,17 @@ def test_backup_operations_report_an_uninitialized_path_as_an_invariant_failure(
|
||||
|
||||
|
||||
def test_energy_management_initializes_its_start_datetime_once(
|
||||
config_eos: ConfigEOS,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
clock = MagicMock(return_value=to_datetime("2024-01-01T12:34:56+01:00"))
|
||||
monkeypatch.setattr("akkudoktoreos.core.ems.to_datetime", clock)
|
||||
|
||||
def fixed_datetime(*args, **kwargs):
|
||||
return to_datetime(*args, **kwargs) if args or kwargs else clock()
|
||||
|
||||
monkeypatch.setattr("akkudoktoreos.core.ems.to_datetime", fixed_datetime)
|
||||
monkeypatch.setattr(EnergyManagement, "_start_datetime", None)
|
||||
monkeypatch.setattr(EnergyManagement, "_observation_datetime", None)
|
||||
ems = EnergyManagement()
|
||||
|
||||
first = ems.start_datetime
|
||||
@@ -159,7 +165,7 @@ async def test_energy_calculation_requires_resolvable_record_times(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
measurement = Measurement()
|
||||
measurement.records = [MeasurementDataRecord()]
|
||||
monkeypatch.setattr(measurement, "records", [MeasurementDataRecord()])
|
||||
monkeypatch.setattr(Measurement, "min_datetime", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(Measurement, "max_datetime", AsyncMock(return_value=None))
|
||||
with pytest.raises(ValueError, match="Start and end datetimes are required"):
|
||||
|
||||
+41
-9
@@ -12,14 +12,14 @@ config path from ``self.device_id`` without needing an external index.
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| batteries | `EOS_DEVICES__BATTERIES` | `dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings] | None` | `rw` | `None` | Stationary battery storage devices, keyed by device_id. |
|
||||
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings] | None` | `rw` | `None` | Electric vehicle battery packs, keyed by device_id. |
|
||||
| batteries | `EOS_DEVICES__BATTERIES` | `Optional[dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings]]` | `rw` | `None` | Stationary battery storage devices, keyed by device_id. |
|
||||
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings]]` | `rw` | `None` | Electric vehicle battery packs, keyed by device_id. |
|
||||
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `dict[str, akkudoktoreos.devices.settings.homeappliancesettings.HomeApplianceCommonSettings]` | `rw` | `required` | Shiftable home appliance devices, keyed by device_id. |
|
||||
| inverters | `EOS_DEVICES__INVERTERS` | `dict[str, akkudoktoreos.devices.settings.invertersettings.InverterCommonSettings] | None` | `rw` | `None` | Inverter devices, keyed by device_id. |
|
||||
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `int | None` | `rw` | `None` | Maximum number of batteries allowed. |
|
||||
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `int | None` | `rw` | `None` | Maximum number of EVs allowed. |
|
||||
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `int | None` | `rw` | `None` | Maximum number of home appliances allowed. |
|
||||
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `int | None` | `rw` | `None` | Maximum number of inverters allowed. |
|
||||
| inverters | `EOS_DEVICES__INVERTERS` | `Optional[dict[str, akkudoktoreos.devices.settings.invertersettings.InverterCommonSettings]]` | `rw` | `None` | Inverter devices, keyed by device_id. |
|
||||
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries allowed. |
|
||||
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of EVs allowed. |
|
||||
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home appliances allowed. |
|
||||
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters allowed. |
|
||||
| measurement_keys | | `list[str]` | `ro` | `N/A` | All measurement keys across all configured devices. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
@@ -35,6 +35,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"batteries": {
|
||||
"bat0": {
|
||||
"device_id": "bat0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 8000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -55,13 +57,21 @@ config path from ``self.device_id`` without needing an external index.
|
||||
1.0
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_batteries": 1,
|
||||
"electric_vehicles": {
|
||||
"ev0": {
|
||||
"device_id": "ev0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -82,7 +92,13 @@ config path from ``self.device_id`` without needing an external index.
|
||||
1.0
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_electric_vehicles": 1,
|
||||
@@ -116,6 +132,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"batteries": {
|
||||
"bat0": {
|
||||
"device_id": "bat0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 8000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -137,6 +155,12 @@ config path from ``self.device_id`` without needing an external index.
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
],
|
||||
"measurement_key_soc_factor": "bat0-soc-factor",
|
||||
"measurement_key_power_l1_w": "bat0-power-l1-w",
|
||||
"measurement_key_power_l2_w": "bat0-power-l2-w",
|
||||
@@ -155,6 +179,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"electric_vehicles": {
|
||||
"ev0": {
|
||||
"device_id": "ev0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -176,6 +202,12 @@ config path from ``self.device_id`` without needing an external index.
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
],
|
||||
"measurement_key_soc_factor": "ev0-soc-factor",
|
||||
"measurement_key_power_l1_w": "ev0-power-l1-w",
|
||||
"measurement_key_power_l2_w": "ev0-power-l2-w",
|
||||
|
||||
+21
-2
@@ -39,6 +39,8 @@
|
||||
"batteries": {
|
||||
"bat0": {
|
||||
"device_id": "bat0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 8000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -59,13 +61,21 @@
|
||||
1.0
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_batteries": 1,
|
||||
"electric_vehicles": {
|
||||
"ev0": {
|
||||
"device_id": "ev0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -86,7 +96,13 @@
|
||||
1.0
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_electric_vehicles": 1,
|
||||
@@ -205,6 +221,9 @@
|
||||
},
|
||||
"measurement": {
|
||||
"historic_hours": 17520,
|
||||
"channels": {},
|
||||
"household": null,
|
||||
"energy_context_seconds": 86400,
|
||||
"load_emr_keys": [
|
||||
"load0_emr"
|
||||
],
|
||||
|
||||
+14
-5
@@ -7,12 +7,15 @@
|
||||
|
||||
| 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 |
|
||||
| channels | `EOS_MEASUREMENT__CHANNELS` | `dict[str, akkudoktoreos.measurement.measurement.MeasurementChannelSettings]` | `rw` | `required` | Typed raw measurement channels keyed by measurement key. |
|
||||
| energy_context_seconds | `EOS_MEASUREMENT__ENERGY_CONTEXT_SECONDS` | `int` | `rw` | `86400` | None |
|
||||
| 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 |
|
||||
| household | `EOS_MEASUREMENT__HOUSEHOLD` | `Optional[akkudoktoreos.measurement.household.HouseholdSettings]` | `rw` | `None` | Optional household energy balance definition. |
|
||||
| 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 -->
|
||||
|
||||
@@ -25,6 +28,9 @@
|
||||
{
|
||||
"measurement": {
|
||||
"historic_hours": 17520,
|
||||
"channels": {},
|
||||
"household": null,
|
||||
"energy_context_seconds": 86400,
|
||||
"load_emr_keys": [
|
||||
"load0_emr"
|
||||
],
|
||||
@@ -51,6 +57,9 @@
|
||||
{
|
||||
"measurement": {
|
||||
"historic_hours": 17520,
|
||||
"channels": {},
|
||||
"household": null,
|
||||
"energy_context_seconds": 86400,
|
||||
"load_emr_keys": [
|
||||
"load0_emr"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user