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 from d2e2d58237. 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 commits f976335, 6dc58c3 and faed0fd by 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 from d2e2d58237. 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

Source d2e2d58237. 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 commits f976335, 6dc58c3 and faed0fd by 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 from d2e2d582 while 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:
Andreas
2026-09-17 20:14:24 +02:00
committed by GitHub
co-authored by Andreas Christin Normann Bobby Noelte r0b2g1t
parent 3c862543a1
commit 04f28997ea
64 changed files with 12540 additions and 1431 deletions
+398
View File
@@ -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"
+29
View File
@@ -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()
+24 -2
View File
@@ -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)
+38
View File
@@ -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
+293
View File
@@ -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)
+385
View File
@@ -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"
+528
View File
@@ -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]
+188
View File
@@ -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]
+438
View File
@@ -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
+165
View File
@@ -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()
+417
View File
@@ -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")]
+66
View File
@@ -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)
+476
View File
@@ -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)
+146
View File
@@ -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
+37 -50
View File
@@ -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
+19 -11
View File
@@ -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
+217
View File
@@ -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.
+115
View File
@@ -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
+78
View File
@@ -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
+49 -6
View File
@@ -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()
+199
View File
@@ -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
+129
View File
@@ -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)) == []
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
],