mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 16:06:40 +00:00
feat: deliver complete GENETIC optimization to main (#1330)
* feat: adapt configuration for multi optimization algorithms Decouple configuration from optimization algorithm parameters. Add to_[algorithm]_param() methods to the configuration that derive optimization algorithm specific parameters from the configuration. Add x-scope tags to the configuration options that describe for which specific algorithms the configuration option is for. The whole device settings are restructured. There are now general settings for the device classes with the afore mentioned to_[algorithm]_param() methods. The general device settings got their own directory `devices/settings`. By this the parameter class also does not have to be a pydantic model which can be used for future optimization/ simulations speed up. Also the parameter class for a device is now part of the device module. This better decouples and also is the natural place for parameters of a device. Besides this feature there are also fixes and improvements: * feat: extend home appliance time window settings and simulation Home appliance can now be configured for multiple runs with per-cycle allowed time windows. The number of remaining cycles to plan is determined at runtime by reading the ``cycles_completed_measurement_key`` from the measurement store. * feat: specialiced CycleTimeWindowSequence for time window sequences Sequence of time windows associated to cycles. This model specializes ``ValueTimeWindowSequence`` so that the ``value`` field of each ``ValueTimeWindow`` encodes the **cycle index** (0-based integer) the window belongs to. Typical use: an appliance that must run ``n`` times per day, each run constrained to a distinct time window. Assign ``value=0`` to windows for the first cycle, ``value=1`` for the second, and so on. Multiple windows may share the same cycle index (their allowed regions are unioned). Windows with ``value=None`` are silently ignored by all cycle-aware methods. * fix: Make test_configmigrate also regard the _ANY_SENTENIEL in key values * chore: Make devices configurations a map instead of a list This makes config paths stable regardless of declaration order and lets each device settings class build its own config path from ``self.device_id`` without needing an external index. Tests are adapted likewise. Devices configurations are automatically migrated from lists to maps. * chore: rename levelized_cost_of_storage_kwh to levelized_cost_of_storage_amt kwh This better fits in the naming scheme and also makes clear the costs are money. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> * fix: runtime config update ignored by config file Runtime settings were handed back to pydantic-settings as init settings, which rank below the config file and the environment. Any key already present in EOS.config.json or in the environment silently discarded the update, so a bulk PUT /v1/config returned 200 without applying anything, while the granular PUT /v1/config/{path} endpoint kept working. Add a dedicated runtime settings source ranked directly below the command line arguments and record granular updates there as well, so both endpoints share one store that survives re-evaluation of the settings sources. Environment variables keep precedence over the config file for all keys that were not set at runtime. Also repairs revert_settings() and update(), which passed their data through the same init settings. Closes #1303 * fix: env vars ignored on first config build ConfigEOS.__init__ passed self as first positional argument to _setup, which forwards it to pydantic_settings.BaseSettings.__init__. Its first positional parameter is _case_sensitive, so the environment source matched the upper case variable names against the lower case field names and returned nothing. Environment settings only took effect after the next configuration setup. * docs: changelog for config priority fixes * fix(config): preserve device identities and storage costs during migration * fix(measurement): restore JSON records into the existing singleton * fix(devices): preserve charge-rate typing and public import compatibility * feat(measurement): integrate typed energy quality and capacity APIs Port the locally backed-up measurement extensions to main async storage and PR #1256 device maps. Preserve runtime capacity estimates across #1305 bulk updates. Confirm JSON singleton restore defect on unchanged main and add regression. No production configuration or measurements included. Co-authored-by: Andreas <drbacke@gmx.de> * docs(measurement): describe household settings and consolidate regression coverage * docs(measurement): regenerate configuration and API contracts * test(measurement): isolate capacity database state between tests * ruff format fix * fix(measurement): restore JSON records into the existing singleton * test(measurement): assert restored timestamps before timezone conversion * test(measurement): assert restored timestamps before timezone conversion * fix: preserve imported feed-in revenue during parameter preparation Cancel GENETIC preparation when imported revenue cannot be read or contains invalid values, preserving the chosen provider instead of replacing it with demo tariffs. Keep valid positive, zero and negative amount/Wh series unchanged. Adapt the revenue-preservation regressions from PRs #1224 and #1304 to the async main API, including real timestamped imports and simulation repricing. The feature-only direct-marketing override remains outside this main fix. Co-authored-by: Christin <info@bikinibottom.capital> Co-authored-by: Normann <github@koldrack.com> * feat(devices): port slot-aware battery export and direct-use physics Port scoped device changes fromd2e2d58237. Keep PR #1256 parameter conversion structure and separate GENETIC0 devices. Validate physical flows and reprice changed simulation results. Co-authored-by: Andreas <drbacke@gmx.de> Co-authored-by: Christin <info@bikinibottom.capital> * docs(measurement): align API version with refreshed prerequisites * fix: return only completed optimization results per run * feat(pvforecast): add calibrated local Akkudoktor backend Port local PV modeling and outage calibration from feature commitsf976335,6dc58c3andfaed0fdby Andreas. Keep PVForecastAkkudoktor identity and remote default, adapt to async storage, and migrate legacy provider settings. * fix(cache): distinguish callables in the shared EMS cache Include the function object in cache keys so methods of one interpolator cannot reuse a probability as a power value. Cover both call orders, keyword arguments, cache hits and separate closures with identical qualified names. * fix(devices): constrain the physics port and validate export levels Defer inactive EV deadline fields to the optimizer port, reject nonfinite export rates, and document the hourly Optimize boundary. Verify converter IDs, rates and LCOS, separate GENETIC0 interpolation, physical boundary flows and independent GENETIC repricing. * docs(pvforecast): regenerate local backend configuration schema * docs(devices): regenerate slot-physics configuration and OpenAPI schemas * test: type dynamic Optimize regression arguments * style: wrap imported tariff test parameter import * style(pvforecast): apply CI import formatting * docs(pvforecast): refresh API version after CI formatting * fix(config): satisfy typed device conversion and migration contracts * docs(config): refresh validated configuration prerequisite schemas * fix(measurement): enforce typed capacity and sample validation * test(devices): align physics regressions with strict type checking * style(measurement): normalize imports for CI * docs(measurement): refresh typed measurement API schemas * docs(devices): refresh API version after prerequisite merge * test: make optimization dispatch timezones explicit * docs(interpolator): use portable reStructuredText markup * docs(devices): refresh API version after docstring compatibility fix * feat: complete configuration-driven GENETIC optimization and reports (#1329) * feat(devices): port slot-aware battery export and direct-use physics Port scoped device changes fromd2e2d58237. Keep PR #1256 parameter conversion structure and separate GENETIC0 devices. Validate physical flows and reprice changed simulation results. Co-authored-by: Andreas <drbacke@gmx.de> Co-authored-by: Christin <info@bikinibottom.capital> * feat(optimization): port tested terminal and tail value primitives Sourced2e2d58237. 22 primitive tests pass; integration with the optimizer, forecast horizon and API is still pending. Co-authored-by: Andreas <drbacke@gmx.de> Co-authored-by: Christin <info@bikinibottom.capital> * fix(devices): preserve charge-rate typing and public import compatibility * feat(measurement): integrate typed energy quality and capacity APIs Port the locally backed-up measurement extensions to main async storage and PR #1256 device maps. Preserve runtime capacity estimates across #1305 bulk updates. Confirm JSON singleton restore defect on unchanged main and add regression. No production configuration or measurements included. Co-authored-by: Andreas <drbacke@gmx.de> * test(integration): validate optimizer economics and document measurement settings * docs(integration): record tested checkpoint and remaining consolidation work * docs(development): define isolated PR packages and remaining porting gates * docs(integration): refresh API version after measurement reconciliation * docs(integration): record PR readiness verification results * docs(development): record publication and verification of PR 1322 * test(measurement): assert restored timestamps before timezone conversion * docs(development): record corrected PR head and CI progress * docs(integration): refresh API version after prerequisite alignment * docs(integration): define parallel packages and Optimize compatibility gates * fix: preserve imported feed-in revenue during parameter preparation Cancel GENETIC preparation when imported revenue cannot be read or contains invalid values, preserving the chosen provider instead of replacing it with demo tariffs. Keep valid positive, zero and negative amount/Wh series unchanged. Adapt the revenue-preservation regressions from PRs #1224 and #1304 to the async main API, including real timestamped imports and simulation repricing. The feature-only direct-marketing override remains outside this main fix. Co-authored-by: Christin <info@bikinibottom.capital> Co-authored-by: Normann <github@koldrack.com> * test(integration): verify tariff protection with mapped device physics * fix: return only completed optimization results per run * test(integration): verify algorithm aliases and mapped-device contracts * fix(cache): distinguish callables in the shared EMS cache Include the function object in cache keys so methods of one interpolator cannot reuse a probability as a power value. Cover both call orders, keyword arguments, cache hits and separate closures with identical qualified names. * fix(devices): constrain the physics port and validate export levels Defer inactive EV deadline fields to the optimizer port, reject nonfinite export rates, and document the hourly Optimize boundary. Verify converter IDs, rates and LCOS, separate GENETIC0 interpolation, physical boundary flows and independent GENETIC repricing. * feat(pvforecast): add calibrated local Akkudoktor backend Port local PV modeling and outage calibration from feature commitsf976335,6dc58c3andfaed0fdby Andreas. Keep PVForecastAkkudoktor identity and remote default, adapt to async storage, and migrate legacy provider settings. * docs(integration): record combined compatibility checks and green JSON PR CI * test: type dynamic Optimize regression arguments * docs(integration): record Optimize fix PR publication * docs(integration): record imported tariff protection PR * style(pvforecast): apply CI import formatting * style(integration): align combined regression imports * test: make optimization dispatch timezones explicit * docs(interpolator): use portable reStructuredText markup * chore: validate combined integration with locked mypy * docs: hand off six validated pull requests for manual review * feat: report genetic interval and terminal value diagnostics * feat(devices): reconcile flexible profiles and EV deadlines with cycle scheduling Adapt the flexible consumer primitives fromd2e2d582while retaining the keyed settings and per-cycle scheduling introduced by #1256. Preserve slot battery physics and GENETIC0 flat-load conversion. Cover energy conservation, deadlines, window intersections, DST, completed cycles and EV converters. * test: satisfy typed genetic PDF chart contracts * feat(optimization): resolve quarter-hour GENETIC requests from configuration * test(genetic): verify real device scheduling, measurement and export contracts Register appliance completed-cycle measurement keys so the real store accepts both default and custom counters. Exercise complete low-budget optimizer runs, persisted measurements, generic solution output and instructions, including zero-power phases, EV departure boundaries, per-cycle windows and LCOS. * fix: bound genetic report forecasts to executable horizon * feat: complete native genetic scheduling and retained result contracts * fix: retain missing raw samples when dropna is disabled * fix: align local optimization slots and measurement instants * docs: explain complete genetic rollout and PR dependencies * feat: expose retained GENETIC report through the versioned API * docs: regenerate complete genetic configuration and API schema * docs: format consolidation and review handoff markdown * test: align isolated EMS fixture with native genetic run options * test(genetic): clean up singleton measurements after device integration tests * test: freeze the clock without replacing timestamp conversion * fix: preserve explicit warmstart timezones in runtime requests * test(genetic): validate device schedules in UTC and Berlin Use explicit Berlin origins for Berlin wall-clock windows, compare absolute deadline instants correctly, and run all real device optimizer scenarios under both UTC and Europe/Berlin. Compare exported starts in the run timezone instead of assuming the output timezone matches the host. * fix: start automatic genetic runs in the site timezone * Preserve aware GENETIC snapshot times across host timezones * docs: specify site clock and rehearsed merge resolutions * test: isolate invalid measurement records and refresh API version * fix: render single-slot genetic tail diagnostics * docs: refresh schema version after report fix * fix: preserve configuration-only Optimize API contract * docs: refresh configuration request schema --------- Co-authored-by: Christin <info@bikinibottom.capital> Co-authored-by: Normann <github@koldrack.com> --------- Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: r0b2g1t <r0b2g1t@users.noreply.github.com> Co-authored-by: Normann <github@koldrack.com> Co-authored-by: Christin <info@bikinibottom.capital>
This commit is contained in:
co-authored by
Andreas
Christin
Normann
Bobby Noelte
r0b2g1t
parent
3c862543a1
commit
04f28997ea
@@ -0,0 +1,398 @@
|
||||
"""Configuration-owned requests and the real automatic GENETIC path."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.config.configmigrate import migrate_config_data
|
||||
from akkudoktoreos.core.ems import EnergyManagement, EnergyManagementStage
|
||||
from akkudoktoreos.core.emsettings import EnergyManagementMode
|
||||
from akkudoktoreos.optimization.genetic import configrequest
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.optimization import (
|
||||
OptimizationAlgorithm,
|
||||
OptimizationCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configured_request(config_eos, monkeypatch):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 24},
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"genetic": {
|
||||
"interval_sec": 900,
|
||||
"horizon_hours": 1,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
"terminal_value_euro_per_kwh": 0.23,
|
||||
"individuals": 10,
|
||||
"generations": 10,
|
||||
"seed": 42,
|
||||
},
|
||||
},
|
||||
"devices": {
|
||||
"max_batteries": 1,
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 1,
|
||||
"max_home_appliances": 2,
|
||||
"batteries": {
|
||||
"storage": {
|
||||
"capacity_wh": 2000,
|
||||
"max_charge_power_w": 1000,
|
||||
"levelized_cost_of_storage_amt_kwh": 0.01,
|
||||
}
|
||||
},
|
||||
"electric_vehicles": {},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
|
||||
"home_appliances": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
start = to_datetime("2026-09-12T10:15:00Z", in_timezone="UTC")
|
||||
ems = SimpleNamespace(
|
||||
start_datetime=start, observation_datetime=start, genetic_solution=lambda: None
|
||||
)
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
measurement = Mock(key_to_lists=AsyncMock(return_value=([], [])))
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "measurement", measurement)
|
||||
data = {
|
||||
"soc": {"storage": 42},
|
||||
"forecasts": {
|
||||
"pv_forecast_wh": [100.0] * 96,
|
||||
"total_load": [200.0] * 96,
|
||||
"electricity_price_per_wh": [0.0003] * 96,
|
||||
"feed_in_tariff_per_wh": [0.00008] * 96,
|
||||
},
|
||||
}
|
||||
return config_eos, ems, measurement, data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hardware_costs_and_state_are_resolved_without_config_changes(configured_request):
|
||||
config, _, _, data = configured_request
|
||||
before = config.model_dump_json()
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.device_id == "storage"
|
||||
assert parameters.pv_battery.capacity_wh == 2000
|
||||
assert parameters.pv_battery.initial_soc_percentage == 42
|
||||
assert parameters.pv_battery.levelized_cost_of_storage_kwh == 0.01
|
||||
assert parameters.ems.price_per_wh_battery == pytest.approx(0.00023)
|
||||
assert parameters.forecast_interval_seconds == 900
|
||||
assert config.model_dump_json() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["devices", "optimization", "pv_battery", "inverter", "ems"])
|
||||
def test_static_http_overrides_are_rejected(field):
|
||||
with pytest.raises(ValidationError):
|
||||
ConfigOptimizationRequest.model_validate({field: {}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_timezone", ["UTC", "Europe/Berlin"])
|
||||
def test_warmstart_timestamp_retains_explicit_zone_and_json_instant(
|
||||
set_other_timezone, host_timezone
|
||||
):
|
||||
set_other_timezone(host_timezone)
|
||||
previous = to_datetime("2026-10-25T02:30:00+01:00", in_timezone="Europe/Berlin")
|
||||
request = ConfigOptimizationRequest(start_solution_datetime=previous)
|
||||
assert request.start_solution_datetime is not None
|
||||
assert request.start_solution_datetime.timezone_name == "Europe/Berlin"
|
||||
assert request.start_solution_datetime.timestamp() == previous.timestamp()
|
||||
restored = ConfigOptimizationRequest.model_validate_json(request.model_dump_json())
|
||||
assert restored.start_solution_datetime is not None
|
||||
assert restored.start_solution_datetime.timestamp() == previous.timestamp()
|
||||
assert restored.start_solution_datetime.utcoffset() == previous.utcoffset()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("age,value", [(301, 0.45), (-1, 0.45), (1, None), (1, np.nan), (1, 1.1)])
|
||||
async def test_missing_stale_future_or_invalid_soc_never_becomes_zero(
|
||||
configured_request, age, value
|
||||
):
|
||||
_, ems, measurement, data = configured_request
|
||||
data["soc"] = {}
|
||||
measurement.key_to_lists.return_value = (
|
||||
[ems.observation_datetime.subtract(seconds=age)],
|
||||
[value],
|
||||
)
|
||||
with pytest.raises(ValueError, match="Fresh SoC missing"):
|
||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soc_freshness_uses_actual_run_time_inside_quarter_hour(configured_request):
|
||||
_, ems, measurement, data = configured_request
|
||||
data["soc"] = {}
|
||||
ems.observation_datetime = ems.start_datetime.add(minutes=7)
|
||||
measurement.key_to_lists.return_value = (
|
||||
[ems.observation_datetime.subtract(seconds=30)],
|
||||
[0.456],
|
||||
)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.initial_soc_percentage == 45
|
||||
assert measurement.key_to_lists.call_args.kwargs[
|
||||
"end_datetime"
|
||||
] == ems.observation_datetime.add(seconds=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_future_soc_in_repeated_hour_is_rejected(configured_request):
|
||||
_, ems, measurement, data = configured_request
|
||||
data["soc"] = {}
|
||||
ems.observation_datetime = to_datetime("2026-10-25T02:45:00+02:00", in_timezone="Europe/Berlin")
|
||||
measurement.key_to_lists.return_value = (
|
||||
[to_datetime("2026-10-25T02:15:00+01:00", in_timezone="Europe/Berlin")],
|
||||
[0.8],
|
||||
)
|
||||
with pytest.raises(ValueError, match="Fresh SoC missing"):
|
||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_soc_and_mismatched_device_link_are_rejected(configured_request):
|
||||
config, _, _, data = configured_request
|
||||
request = ConfigOptimizationRequest.model_validate(data)
|
||||
request.soc["unknown"] = 20
|
||||
with pytest.raises(ValueError, match="unconfigured"):
|
||||
await request.resolve()
|
||||
assert config.devices.inverters is not None
|
||||
config.devices.inverters["inverter"].battery_id = "missing"
|
||||
with pytest.raises(ValueError, match="battery_id"):
|
||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tariff", [0.00008, 0.0, -0.00005])
|
||||
async def test_direct_marketing_keeps_explicit_imported_sale_prices(configured_request, tariff):
|
||||
config, _, _, data = configured_request
|
||||
config.feedintariff.direct_marketing_enabled = True
|
||||
config.feedintariff.provider = "FeedInTariffImport"
|
||||
data["forecasts"]["feed_in_tariff_per_wh"] = [tariff] * 96
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.ems.feed_in_tariff_per_wh == [tariff] * 96
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_gaps_fail_but_shorter_tail_is_accepted(configured_request):
|
||||
_, _, _, data = configured_request
|
||||
data["forecasts"]["electricity_price_per_wh"] = [0.0003] * 50
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert len(parameters.ems.total_load) == 50
|
||||
data["forecasts"]["electricity_price_per_wh"][42] = np.nan
|
||||
with pytest.raises(ValueError, match="control horizon"):
|
||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_power_is_integrated_once_and_update_is_awaited(
|
||||
configured_request, monkeypatch
|
||||
):
|
||||
_, _, _, data = configured_request
|
||||
data["forecasts"] = {}
|
||||
series = {
|
||||
"pvforecast_ac_power": 1000.0,
|
||||
"loadforecast_power_w": 2000.0,
|
||||
"elecprice_marketprice_wh": 0.0003,
|
||||
"feed_in_tariff_wh": -0.00005,
|
||||
}
|
||||
|
||||
async def read(key, **kwargs):
|
||||
return pd.Series(
|
||||
[series[key]] * 48, index=pd.date_range("2026-09-12T00:00:00Z", periods=48, freq="h")
|
||||
)
|
||||
|
||||
prediction = Mock(update_data=AsyncMock(), key_to_raw_series=AsyncMock(side_effect=read))
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "prediction", prediction)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.ems.pv_forecast_wh == [250.0] * len(parameters.ems.pv_forecast_wh)
|
||||
assert parameters.ems.total_load == [500.0] * len(parameters.ems.total_load)
|
||||
assert parameters.ems.feed_in_tariff_per_wh == [-0.00005] * len(parameters.ems.total_load)
|
||||
prediction.update_data.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stamp,interval,expected",
|
||||
[
|
||||
("2026-03-29T03:17:00+02:00", 900, "2026-03-29T03:15:00+02:00"),
|
||||
("2026-10-25T02:47:00+01:00", 900, "2026-10-25T02:45:00+01:00"),
|
||||
("2026-10-25T02:47:00+01:00", 3600, "2026-10-25T02:00:00+01:00"),
|
||||
],
|
||||
)
|
||||
def test_slot_alignment_preserves_dst_fold(config_eos, stamp, interval, expected):
|
||||
time = to_datetime(stamp, in_timezone="Europe/Berlin")
|
||||
aligned = EnergyManagement.set_start_datetime(time, interval_seconds=interval)
|
||||
assert aligned == to_datetime(expected, in_timezone="Europe/Berlin")
|
||||
assert aligned.utcoffset() == to_datetime(expected, in_timezone="Europe/Berlin").utcoffset()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timezone", ["Asia/Kolkata", "Asia/Kathmandu"])
|
||||
@pytest.mark.parametrize("interval,minute", [(900, 30), (3600, 0)])
|
||||
def test_slot_alignment_uses_local_midnight(config_eos, timezone, interval, minute):
|
||||
time = to_datetime("2026-09-12T10:40:00", in_timezone=timezone)
|
||||
aligned = EnergyManagement.set_start_datetime(time, interval_seconds=interval)
|
||||
assert aligned.hour == 10
|
||||
assert aligned.minute == minute
|
||||
assert aligned.timezone_name == timezone
|
||||
assert EnergyManagement().observation_datetime == time
|
||||
|
||||
|
||||
def test_old_feature_config_migrates_without_changing_explicit_nested_values(config_eos):
|
||||
raw = {
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"interval": 900,
|
||||
"horizon_hours": 12,
|
||||
"terminal_value_euro_per_kwh": 0.23,
|
||||
"genetic": {"horizon_hours": 8},
|
||||
}
|
||||
}
|
||||
migrated = migrate_config_data(raw)
|
||||
assert migrated.optimization.genetic.interval_sec == 900
|
||||
assert migrated.optimization.genetic.horizon_hours == 8
|
||||
assert migrated.optimization.genetic.terminal_value_euro_per_kwh == 0.23
|
||||
settings = OptimizationCommonSettings.model_validate(raw["optimization"])
|
||||
settings.algorithm = OptimizationAlgorithm.GENETIC0
|
||||
assert settings.genetic.interval_sec == 900
|
||||
assert settings.genetic.generations == 400
|
||||
|
||||
|
||||
def test_http_empty_body_is_configuration_request_and_failure_cannot_return_cache(monkeypatch):
|
||||
from akkudoktoreos.server import eos
|
||||
|
||||
run = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(run=run))
|
||||
client = TestClient(eos.app)
|
||||
response = client.post("/v1/optimize")
|
||||
assert response.status_code == 503
|
||||
assert isinstance(run.call_args.kwargs["genetic_parameters"], ConfigOptimizationRequest)
|
||||
assert run.call_args.kwargs["algorithm"] == OptimizationAlgorithm.GENETIC
|
||||
response = client.post("/v1/optimize", json={"devices": {}})
|
||||
assert response.status_code == 422
|
||||
assert run.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["start_hour=4", "ngen=1", "interval=3600", "unknown="])
|
||||
def test_http_configuration_request_rejects_query_overrides(monkeypatch, query):
|
||||
from akkudoktoreos.server import eos
|
||||
|
||||
run = AsyncMock()
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(run=run))
|
||||
response = TestClient(eos.app).post(f"/v1/optimize?{query}", json={})
|
||||
assert response.status_code == 422
|
||||
assert "query overrides are not supported" in response.text
|
||||
run.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("automatic", [False, True])
|
||||
async def test_real_ems_returns_coherent_quarter_hour_solution_and_plan(
|
||||
configured_request, monkeypatch, automatic
|
||||
):
|
||||
_, fake, _, data = configured_request
|
||||
ems = EnergyManagement()
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
monkeypatch.setattr(EnergyManagement, "prediction", Mock(update_data=AsyncMock()))
|
||||
monkeypatch.setattr(EnergyManagement, "adapter", Mock(update_data=AsyncMock()))
|
||||
request = ConfigOptimizationRequest.model_validate(data)
|
||||
if automatic:
|
||||
original = ConfigOptimizationRequest.resolve
|
||||
|
||||
async def resolve_default(self):
|
||||
return await original(request)
|
||||
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "resolve", resolve_default)
|
||||
solution = await ems.run(
|
||||
start_datetime=fake.start_datetime.add(minutes=2),
|
||||
mode=EnergyManagementMode.OPTIMIZATION,
|
||||
genetic_parameters=None if automatic else request,
|
||||
genetic_generations=10,
|
||||
genetic_seed=42,
|
||||
)
|
||||
assert solution is not None
|
||||
assert solution is ems.genetic_solution()
|
||||
assert solution.start_solution_datetime == fake.start_datetime
|
||||
assert len(solution.ac_charge) == 4
|
||||
assert ems.optimization_solution() is not None
|
||||
assert ems.plan() is not None
|
||||
assert ems.stage() == EnergyManagementStage.IDLE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_automatic_run_reads_real_resolver_provider_units_and_measured_soc(
|
||||
configured_request, monkeypatch
|
||||
):
|
||||
_, fake, measurement, _ = configured_request
|
||||
ems = EnergyManagement()
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
monkeypatch.setattr(EnergyManagement, "_genetic_solution", None)
|
||||
measurement.key_to_lists.return_value = ([fake.start_datetime], [0.42])
|
||||
values = {
|
||||
"pvforecast_ac_power": 1000.0,
|
||||
"loadforecast_power_w": 2000.0,
|
||||
"elecprice_marketprice_wh": 0.0003,
|
||||
"feed_in_tariff_wh": 0.00008,
|
||||
}
|
||||
|
||||
async def read(key, **kwargs):
|
||||
return pd.Series(
|
||||
[values[key]] * 48, index=pd.date_range("2026-09-12T00:00:00Z", periods=48, freq="h")
|
||||
)
|
||||
|
||||
prediction = Mock(update_data=AsyncMock(), key_to_raw_series=AsyncMock(side_effect=read))
|
||||
monkeypatch.setattr(EnergyManagement, "prediction", prediction)
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "prediction", prediction)
|
||||
monkeypatch.setattr(EnergyManagement, "adapter", Mock(update_data=AsyncMock()))
|
||||
solution = await ems.run(
|
||||
start_datetime=fake.start_datetime,
|
||||
mode=EnergyManagementMode.OPTIMIZATION,
|
||||
genetic_generations=10,
|
||||
genetic_seed=42,
|
||||
)
|
||||
assert solution is not None
|
||||
assert solution.parameters is not None
|
||||
assert solution.parameters.pv_battery is not None
|
||||
assert solution.parameters.pv_battery.initial_soc_percentage == 42
|
||||
assert solution.parameters.ems.pv_forecast_wh[:4] == [250.0] * 4
|
||||
assert solution.parameters.ems.total_load[:4] == [500.0] * 4
|
||||
assert len(solution.ac_charge) == 4
|
||||
prediction.key_to_raw_series.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_real_genetic_result_serializes_native_quarter_hour_contract(
|
||||
configured_request, monkeypatch
|
||||
):
|
||||
from akkudoktoreos.server import eos
|
||||
|
||||
_, fake, _, data = configured_request
|
||||
ems = EnergyManagement()
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
monkeypatch.setattr(EnergyManagement, "_genetic_solution", None)
|
||||
monkeypatch.setattr(EnergyManagement, "prediction", Mock(update_data=AsyncMock()))
|
||||
monkeypatch.setattr(EnergyManagement, "adapter", Mock(update_data=AsyncMock()))
|
||||
|
||||
async def run(**kwargs):
|
||||
return await ems.run(start_datetime=fake.start_datetime, **kwargs)
|
||||
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(run=run))
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=eos.app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.post("/v1/optimize", json=data)
|
||||
assert response.status_code == 200, response.text
|
||||
result = response.json()
|
||||
assert result["interval_seconds"] == 900
|
||||
assert result["controls_start_at_now"] is True
|
||||
assert len(result["ac_charge"]) == 4
|
||||
assert result["parameters"]["pv_battery"]["device_id"] == "storage"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Verify fallback JSON loading does not lose records through the singleton."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_measurement
|
||||
from akkudoktoreos.core.dataabc import DataSequence
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_measurement_json_roundtrip(config_eos, tmp_path, monkeypatch):
|
||||
m = get_measurement()
|
||||
config_eos.measurement.load_emr_keys = ["meter"]
|
||||
config_eos.general.data_folder_path = tmp_path
|
||||
config_eos.database.provider = None
|
||||
m._db_reset_state()
|
||||
try:
|
||||
await m.update_value(to_datetime("2026-09-16T08:00:00Z"), "meter", 123.5)
|
||||
monkeypatch.setattr(DataSequence, "save", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(DataSequence, "load", AsyncMock(return_value=False))
|
||||
assert await m.save()
|
||||
m._db_reset_state()
|
||||
assert await m.load()
|
||||
assert len(m.records) == 1
|
||||
assert m.records[0]["meter"] == 123.5
|
||||
finally:
|
||||
m._db_reset_state()
|
||||
@@ -994,10 +994,11 @@ class TestDataSequence:
|
||||
data_dict_keep = await sequence.key_to_dict("data_value", dropna=False)
|
||||
assert pd.isna(data_dict_keep[to_datetime(datetime(2023, 11, 6), as_string=True)])
|
||||
|
||||
async def test_key_to_lists_dropna_removes_nan(self, sequence):
|
||||
@pytest.mark.parametrize("missing", [None, float("nan")])
|
||||
async def test_key_to_lists_dropna_removes_nan(self, sequence, missing):
|
||||
"""`dropna=True` (default) must drop records whose value is NaN, not just None."""
|
||||
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
|
||||
record2 = self.create_test_record(datetime(2023, 11, 6), float("nan"))
|
||||
record2 = self.create_test_record(datetime(2023, 11, 6), missing)
|
||||
record3 = self.create_test_record(datetime(2023, 11, 7), 0.9)
|
||||
await sequence.insert_by_datetime(record1)
|
||||
await sequence.insert_by_datetime(record2)
|
||||
@@ -1013,6 +1014,27 @@ class TestDataSequence:
|
||||
assert len(values_keep) == 3
|
||||
assert pd.isna(values_keep[1])
|
||||
|
||||
async def test_raw_none_record_keeps_forecast_gap_and_true_interval_length(self, sequence):
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
|
||||
start = to_datetime("2026-09-16T10:00:00Z", in_timezone="UTC")
|
||||
for minute, value in [(0, 100.0), (15, None), (30, 100.0)]:
|
||||
await sequence.insert_by_datetime(
|
||||
self.create_test_record(start.add(minutes=minute), value)
|
||||
)
|
||||
raw = await sequence.key_to_raw_series("data_value", dropna=False)
|
||||
assert len(raw) == 3
|
||||
assert raw.index[1].timestamp() == start.add(minutes=15).timestamp()
|
||||
assert pd.isna(raw.iloc[1])
|
||||
filtered = await sequence.key_to_raw_series("data_value")
|
||||
assert filtered.tolist() == [100.0, 100.0]
|
||||
values = await bounded_forecast_array(
|
||||
sequence, key="data_value", start_datetime=start,
|
||||
end_datetime=start.add(hours=1), interval=to_duration(900),
|
||||
)
|
||||
assert values[[0, 2]].tolist() == [100.0, 100.0]
|
||||
assert np.isnan(values[[1, 3]]).all()
|
||||
|
||||
async def test_to_dataframe_full_data(self, sequence):
|
||||
"""Test conversion of all records to a DataFrame without filtering."""
|
||||
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Repeated local clock times must dispatch by their real UTC instant."""
|
||||
|
||||
from akkudoktoreos.core.emplan import EnergyManagementPlan, FRBCInstruction
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
def test_repeated_hour_keeps_instruction_order_and_dispatch():
|
||||
first = to_datetime("2026-10-25T02:45:00+02:00").in_timezone("Europe/Berlin")
|
||||
second = to_datetime("2026-10-25T02:15:00+01:00").in_timezone("Europe/Berlin")
|
||||
plan = EnergyManagementPlan(id="fold", generated_at=first, instructions=[])
|
||||
later = FRBCInstruction(
|
||||
resource_id="battery1",
|
||||
actuator_id="battery1",
|
||||
execution_time=second,
|
||||
operation_mode_id="IDLE",
|
||||
operation_mode_factor=1.0,
|
||||
)
|
||||
earlier = FRBCInstruction(
|
||||
resource_id="battery1",
|
||||
actuator_id="battery1",
|
||||
execution_time=first,
|
||||
operation_mode_id="GRID_SUPPORT_IMPORT",
|
||||
operation_mode_factor=0.5,
|
||||
)
|
||||
plan.add_instruction(later)
|
||||
plan.add_instruction(earlier)
|
||||
assert [item.execution_time.timestamp() for item in plan.instructions] == [
|
||||
first.timestamp(),
|
||||
second.timestamp(),
|
||||
]
|
||||
assert plan.valid_from is not None
|
||||
assert plan.valid_from.timestamp() == first.timestamp()
|
||||
assert plan.valid_until is None
|
||||
between = to_datetime("2026-10-25T02:00:00+01:00").in_timezone("Europe/Berlin")
|
||||
assert plan.get_active_instructions(between) == [earlier]
|
||||
assert plan.get_next_instruction(between) == later
|
||||
assert plan.get_active_instructions(second) == [later]
|
||||
assert plan.get_next_instruction(second) is None
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Profiles, cycle windows and deadlines on the GENETIC slot grid."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.devices.devicesabc import (
|
||||
ConsumerDeadlinePolicy,
|
||||
ConsumerScheduleMode,
|
||||
)
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, ElectricVehicleParameters
|
||||
from akkudoktoreos.devices.genetic.homeappliance import (
|
||||
HomeAppliance,
|
||||
HomeApplianceParameters,
|
||||
resample_power_to_slot_energy,
|
||||
)
|
||||
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
|
||||
from akkudoktoreos.devices.settings.homeappliancesettings import (
|
||||
HomeApplianceCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
def appliance(*, slots: int = 192, slot_h: float = 0.25, **kwargs: Any) -> HomeAppliance:
|
||||
return HomeAppliance(
|
||||
HomeApplianceParameters.model_validate(
|
||||
{
|
||||
"device_id": "washer",
|
||||
"load_profile_power_w": [1200.0, 600.0, 300.0],
|
||||
"load_profile_interval_seconds": 600,
|
||||
**kwargs,
|
||||
}
|
||||
),
|
||||
optimization_hours=48,
|
||||
prediction_hours=slots,
|
||||
slot_duration_h=slot_h,
|
||||
)
|
||||
|
||||
|
||||
def test_noninteger_resampling_conserves_real_energy() -> None:
|
||||
# 10-minute 1.2kW/0.6kW/0.3kW phases: 350Wh, delivered in two 15min slots.
|
||||
device = appliance()
|
||||
np.testing.assert_allclose(device.run_energy_wh, [250.0, 100.0])
|
||||
device.build_load_curve([3, 12])
|
||||
assert device.get_load_curve().sum() == pytest.approx(700)
|
||||
np.testing.assert_allclose(device.get_load_curve()[3:5], [250.0, 100.0])
|
||||
assert device.run_slots == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_s,slot_s", [(600, 900), (1200, 900), (900, 3600), (3600, 900)])
|
||||
def test_profile_energy_independent_of_slot_grid(input_s: int, slot_s: int) -> None:
|
||||
result = resample_power_to_slot_energy([400.0, 1600.0, 200.0], input_s, slot_s)
|
||||
assert result.sum() == pytest.approx(2200 * input_s / 3600)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", [[], [-1], [float("nan")], [float("inf")]])
|
||||
def test_invalid_profiles_rejected_in_parameters_and_settings(profile: list[float]) -> None:
|
||||
for model in (HomeApplianceParameters, HomeApplianceCommonSettings):
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"device_id": "bad", "load_profile_power_w": profile})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fields", [{"duration_h": 2}, {"consumption_wh": 500}, {"duration_h": 2, "consumption_wh": 500}]
|
||||
)
|
||||
def test_profile_cannot_silently_override_explicit_flat_definition(fields: dict[str, int]) -> None:
|
||||
for model in (HomeApplianceParameters, HomeApplianceCommonSettings):
|
||||
with pytest.raises(ValidationError, match="Conflicting"):
|
||||
model.model_validate({"device_id": "bad", "load_profile_power_w": [100.0], **fields})
|
||||
|
||||
|
||||
def test_settings_profile_roundtrip_and_legacy_defaults() -> None:
|
||||
defaults = HomeApplianceCommonSettings(device_id="legacy")
|
||||
assert defaults.to_genetic_param().consumption_wh == 3000
|
||||
assert defaults.to_genetic0_param().duration_h == 3
|
||||
settings = HomeApplianceCommonSettings.model_validate(
|
||||
{
|
||||
"device_id": "washer",
|
||||
"load_profile_power_w": [1200.0, 600.0],
|
||||
"load_profile_interval_seconds": 600,
|
||||
"schedule_mode": "DAILY",
|
||||
"num_cycles": 2,
|
||||
"min_cycle_gap_h": 1,
|
||||
"time_windows": {"windows": [{"start_time": "07:00", "duration": "12 hours"}]},
|
||||
}
|
||||
)
|
||||
restored = HomeApplianceCommonSettings.model_validate_json(settings.model_dump_json())
|
||||
params = restored.to_genetic_param()
|
||||
assert params.load_profile_power_w == [1200.0, 600.0]
|
||||
assert params.consumption_wh is None and params.duration_h is None
|
||||
assert params.num_cycles == 2 and params.min_cycle_gap_h == 1
|
||||
assert params.schedule_mode == ConsumerScheduleMode.DAILY
|
||||
assert params.shared_time_windows is not None
|
||||
with pytest.raises(ValueError, match="GENETIC"):
|
||||
restored.to_genetic0_param()
|
||||
|
||||
|
||||
def test_cycle_and_shared_windows_intersect_after_completed_cycle() -> None:
|
||||
settings = HomeApplianceCommonSettings.model_validate(
|
||||
{
|
||||
"device_id": "washer",
|
||||
"load_profile_power_w": [1200.0],
|
||||
"load_profile_interval_seconds": 1800,
|
||||
"schedule_mode": "DAILY",
|
||||
"cycle_time_windows": {
|
||||
"windows": [
|
||||
{"start_time": "06:00", "duration": "2 hours", "value": 0},
|
||||
{"start_time": "18:00", "duration": "2 hours", "value": 1},
|
||||
]
|
||||
},
|
||||
"time_windows": {"windows": [{"start_time": "18:30", "duration": "1 hour"}]},
|
||||
}
|
||||
)
|
||||
device = HomeAppliance(settings.to_genetic_param(), 48, 192, 0.25)
|
||||
device.set_completed_cycles(1)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.remaining_cycle_indices == [1]
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96, cycle_index=1
|
||||
) == [74, 75, 76]
|
||||
assert (
|
||||
device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96, cycle_index=0
|
||||
)
|
||||
== []
|
||||
)
|
||||
# Next-day DAILY lookup remains possible using absolute configured cycle IDs.
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=96, horizon_end_slot=192, cycle_index=1
|
||||
) == [170, 171, 172]
|
||||
|
||||
|
||||
def test_legacy_multiple_cycles_use_slots_and_physical_idle_gap() -> None:
|
||||
device = appliance(slots=96, num_cycles=2, min_cycle_gap_h=1)
|
||||
assert device.set_starting_times([4, 4]) == [4, 10]
|
||||
assert device.get_load_curve().sum() == pytest.approx(700)
|
||||
|
||||
|
||||
def test_touching_cycle_windows_preserve_union() -> None:
|
||||
device = appliance(
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "08:00", "duration": "15 minutes", "value": 0},
|
||||
{"start_time": "08:15", "duration": "15 minutes", "value": 0},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin"),
|
||||
earliest_slot=0,
|
||||
horizon_end_slot=96,
|
||||
cycle_index=0,
|
||||
) == [32]
|
||||
|
||||
|
||||
def test_absolute_bounds_round_inward_on_quarter_hour_grid() -> None:
|
||||
device = appliance(
|
||||
earliest_start_datetime="2026-09-16T08:01:00+02:00",
|
||||
deadline_datetime="2026-09-16T09:01:00+02:00",
|
||||
deadline_policy="STRICT",
|
||||
)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96
|
||||
) == [33, 34]
|
||||
assert not device.deadline_missed([34], zero)
|
||||
assert device.deadline_missed([35], zero)
|
||||
|
||||
|
||||
def test_best_effort_relaxes_only_deadline_not_window_or_earliest() -> None:
|
||||
device = appliance(
|
||||
deadline_datetime="2026-09-16T08:00:00+02:00",
|
||||
shared_time_windows={"windows": [{"start_time": "09:00", "duration": "1 hour"}]},
|
||||
)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96
|
||||
) == [36]
|
||||
assert device.deadline_relaxed and device.deadline_missed([36], zero)
|
||||
device.deadline_policy = ConsumerDeadlinePolicy.STRICT
|
||||
assert (
|
||||
device.allowed_start_slots(slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96) == []
|
||||
)
|
||||
assert not device.deadline_relaxed
|
||||
|
||||
|
||||
def test_overnight_window_uses_opening_date_and_weekday() -> None:
|
||||
device = appliance(
|
||||
shared_time_windows={
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "23:00",
|
||||
"duration": "3 hours",
|
||||
"date": "2026-09-15",
|
||||
"day_of_week": 1,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96
|
||||
) == list(range(7))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("date,expected", [("2026-03-29", 8), ("2026-10-25", 16)])
|
||||
def test_dst_elapsed_slots_and_local_window(date: str, expected: int) -> None:
|
||||
zero = to_datetime(f"{date}T00:00:00", in_timezone="Europe/Berlin")
|
||||
device = appliance(
|
||||
shared_time_windows={"windows": [{"start_time": "03:00", "duration": "30 minutes"}]}
|
||||
)
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=100
|
||||
) == [expected]
|
||||
assert device.run_end_datetime(expected, zero).hour == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("starts", [[-1], [191], [192]])
|
||||
def test_complete_curve_does_not_silently_truncate_runs(starts: list[int]) -> None:
|
||||
with pytest.raises(ValueError, match="complete"):
|
||||
appliance().build_load_curve(starts)
|
||||
|
||||
|
||||
def test_ev_deadlines_roundtrip_without_changing_slot_physics() -> None:
|
||||
settings = BatteriesCommonSettings.model_validate(
|
||||
{
|
||||
"device_id": "car",
|
||||
"capacity_wh": 10000,
|
||||
"charging_efficiency": 0.8,
|
||||
"max_charge_power_w": 4000,
|
||||
"min_soc_deadline_datetime": "2026-09-16T07:30:00+02:00",
|
||||
"min_soc_max_duration_h": 2.5,
|
||||
}
|
||||
)
|
||||
params = settings.to_genetic_ev_bat_param()
|
||||
assert params.min_soc_max_duration_h == 2.5
|
||||
assert params.min_soc_deadline_datetime is not None
|
||||
assert params.min_soc_deadline_datetime.in_timezone("Europe/Berlin").hour == 7
|
||||
battery = Battery(params, prediction_hours=16, slot_duration_h=0.25)
|
||||
battery.charge_array[0] = 1
|
||||
charged, losses = battery.charge_energy(2000, hour=0)
|
||||
assert charged == pytest.approx(800)
|
||||
assert losses == pytest.approx(200)
|
||||
assert "min_soc_deadline_datetime" not in settings.to_genetic0_ev_bat_param().model_dump()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duration", [0.0, -1.0, float("inf"), float("nan")])
|
||||
def test_ev_invalid_departure_durations_rejected(duration: float) -> None:
|
||||
for model in (ElectricVehicleParameters, BatteriesCommonSettings):
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate(
|
||||
{"device_id": "car", "capacity_wh": 10000, "min_soc_max_duration_h": duration}
|
||||
)
|
||||
|
||||
|
||||
def test_completed_cycles_parameter_initializes_remaining_global_ids() -> None:
|
||||
device = appliance(num_cycles=3, completed_cycles=2)
|
||||
assert device.completed_cycles == 2
|
||||
assert device.remaining_cycle_indices == [2]
|
||||
assert device.num_remaining_cycles == 1
|
||||
done = appliance(num_cycles=3, completed_cycles=3)
|
||||
assert done.remaining_cycle_indices == []
|
||||
with pytest.raises(ValidationError, match="completed_cycles"):
|
||||
appliance(num_cycles=2, completed_cycles=3)
|
||||
|
||||
|
||||
def test_best_effort_multiple_cycles_retain_room_for_joint_gap_repair() -> None:
|
||||
device = appliance(
|
||||
num_cycles=2,
|
||||
min_cycle_gap_h=1,
|
||||
deadline_datetime="2026-09-16T08:00:00+02:00",
|
||||
shared_time_windows={"windows": [{"start_time": "09:00", "duration": "3 hours"}]},
|
||||
)
|
||||
zero = to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=96, cycle_index=0
|
||||
) == list(range(36, 47))
|
||||
assert device.deadline_relaxed
|
||||
device.build_load_curve([36, 42])
|
||||
assert device.get_load_curve().sum() == pytest.approx(700)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deadline", ["2026-09-16T07:00:00Z", "2026-09-16T09:00:00+02:00"])
|
||||
def test_absolute_deadline_offsets_describe_same_instant(deadline: str) -> None:
|
||||
device = appliance(deadline_datetime=deadline, deadline_policy="STRICT")
|
||||
zero = to_datetime("2026-09-16T08:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
assert device.allowed_start_slots(
|
||||
slot0_datetime=zero, earliest_slot=0, horizon_end_slot=16
|
||||
) == [0, 1, 2]
|
||||
assert not device.deadline_missed([2], zero)
|
||||
assert device.deadline_missed([3], zero)
|
||||
@@ -0,0 +1,385 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
ems_eos = get_ems(init=True) # init once
|
||||
|
||||
DIR_TESTDATA = Path(__file__).parent / "testdata"
|
||||
|
||||
|
||||
def test_direct_marketing_preserves_constant_supplied_feed_in_tariff(config_eos: ConfigEOS):
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0, 0.0],
|
||||
"strompreis_euro_pro_wh": [0.0002, -0.0001],
|
||||
"einspeiseverguetung_euro_pro_wh": [0.00007, 0.00007],
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [0.0, 0.0],
|
||||
},
|
||||
pv_battery=None,
|
||||
# Without an inverter the simulation books no grid energy at all, so the
|
||||
# price signal would never reach the fitness.
|
||||
inverter={"device_id": "inverter1", "max_power_wh": 20000},
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
|
||||
adjusted = GeneticOptimization()._parameters_for_config(parameters)
|
||||
|
||||
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.00007, 0.00007]
|
||||
assert parameters.ems.einspeiseverguetung_euro_pro_wh == [0.00007, 0.00007]
|
||||
|
||||
|
||||
def test_direct_marketing_keeps_variable_feed_in_tariff(config_eos: ConfigEOS):
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0, 0.0],
|
||||
"strompreis_euro_pro_wh": [0.0002, 0.0003],
|
||||
"einspeiseverguetung_euro_pro_wh": [0.0001, -0.00005],
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [0.0, 0.0],
|
||||
},
|
||||
pv_battery=None,
|
||||
inverter=None,
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
|
||||
adjusted = GeneticOptimization()._parameters_for_config(parameters)
|
||||
|
||||
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0001, -0.00005]
|
||||
|
||||
|
||||
def test_grid_export_rates_reach_the_solution(config_eos: ConfigEOS):
|
||||
"""Configured export rates end up as per-slot export levels in the solution."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 24},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"individuals": 40,
|
||||
"generations": 10,
|
||||
"tail_horizon_hours": 0,
|
||||
"horizon_hours": 24,
|
||||
"interval_sec": 3600,
|
||||
}
|
||||
},
|
||||
"feedintariff": {"direct_marketing_enabled": True},
|
||||
"devices": {
|
||||
"max_batteries": 1,
|
||||
"batteries": {
|
||||
"battery1": {"device_id": "battery1", "grid_export_rates": [0.5, 1.0]}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
CacheEnergyManagementStore().clear()
|
||||
|
||||
hours = 24
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
"strompreis_euro_pro_wh": [0.0003] * hours,
|
||||
# A pronounced tariff peak makes exporting worthwhile at all.
|
||||
"einspeiseverguetung_euro_pro_wh": [0.0001] * 12 + [0.0009] * 12,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [200.0] * hours,
|
||||
},
|
||||
pv_battery={
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 10000,
|
||||
"initial_soc_percentage": 100,
|
||||
"min_soc_percentage": 0,
|
||||
"max_charge_power_w": 5000,
|
||||
},
|
||||
inverter={
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 10000,
|
||||
"battery_id": "battery1",
|
||||
},
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
|
||||
optimization = GeneticOptimization(fixed_seed=42)
|
||||
solution = optimization.optimize_ems(parameters=parameters, start_hour=0, ngen=3)
|
||||
|
||||
# Full power first, so the full-power state keeps the lowest export index.
|
||||
assert optimization.bat_possible_grid_export_values == [1.0, 0.5]
|
||||
assert len(solution.battery_grid_export_factor) == len(solution.battery_grid_export_allowed)
|
||||
assert set(solution.battery_grid_export_factor) <= {0.0, 0.5, 1.0}
|
||||
assert [
|
||||
1 if factor > 0.0 else 0 for factor in solution.battery_grid_export_factor
|
||||
] == solution.battery_grid_export_allowed
|
||||
|
||||
# @TODO
|
||||
|
||||
|
||||
def _ev_deadline_parameters(hours: int, **ev_extra) -> GeneticOptimizationParameters:
|
||||
"""Optimization parameters with an EV that has to be charged."""
|
||||
return GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
# Expensive for the first six hours, dirt cheap afterwards: without a
|
||||
# deadline the optimizer would always wait for the cheap slots.
|
||||
"strompreis_euro_pro_wh": [0.0009] * 6 + [0.00001] * (hours - 6),
|
||||
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [300.0] * hours,
|
||||
},
|
||||
pv_battery=None,
|
||||
inverter=None,
|
||||
ev={
|
||||
"device_id": "ev1",
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.95,
|
||||
"max_charge_power_w": 11040,
|
||||
"initial_soc_percentage": 20,
|
||||
"min_soc_percentage": 60,
|
||||
**ev_extra,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_ev_deadline_slot_resolution(config_eos: ConfigEOS):
|
||||
"""Datetime and maximum duration resolve to a slot; the earlier one wins."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval_sec": 3600}
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
|
||||
optimization = GeneticOptimization(fixed_seed=1)
|
||||
optimization._slot0_datetime = optimization.ems.start_datetime
|
||||
slot0 = optimization._slot0_datetime
|
||||
|
||||
# Duration only: 6 h after the start hour 10.
|
||||
parameters = _ev_deadline_parameters(48, min_soc_max_duration_h=6)
|
||||
assert optimization._ev_deadline_slot(parameters) == 6
|
||||
|
||||
# Datetime only.
|
||||
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=14))
|
||||
assert optimization._ev_deadline_slot(parameters) == 14
|
||||
|
||||
# Both: the earlier one wins.
|
||||
parameters = _ev_deadline_parameters(
|
||||
48, min_soc_deadline_datetime=slot0.add(hours=20), min_soc_max_duration_h=6
|
||||
)
|
||||
assert optimization._ev_deadline_slot(parameters) == 6
|
||||
|
||||
# Beyond the horizon: no deadline, the end-of-horizon target already covers it.
|
||||
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=100))
|
||||
assert optimization._ev_deadline_slot(parameters) is None
|
||||
|
||||
# In the past: due right now.
|
||||
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.subtract(hours=2))
|
||||
assert optimization._ev_deadline_slot(parameters) == 0
|
||||
|
||||
# No deadline at all.
|
||||
assert optimization._ev_deadline_slot(_ev_deadline_parameters(48)) is None
|
||||
|
||||
|
||||
def test_ev_soc_penalty_reads_the_deadline_slot(config_eos: ConfigEOS):
|
||||
"""With a deadline the penalty checks the SoC at that slot, not at the end."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval_sec": 3600}
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
|
||||
optimization = GeneticOptimization(fixed_seed=1)
|
||||
simulation_result = {"EAuto_SoC_pro_Stunde": [20.0, 35.0, 50.0, 80.0]}
|
||||
|
||||
optimization.simulation.ev = MagicMock(spec=Battery)
|
||||
optimization.simulation.ev.current_soc_percentage.return_value = 80.0
|
||||
|
||||
# Without a deadline the final SoC counts.
|
||||
optimization._ev_soc_deadline_slot = None
|
||||
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
|
||||
|
||||
# With one, the SoC at the beginning of the deadline slot counts.
|
||||
optimization._ev_soc_deadline_slot = 12
|
||||
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 50.0
|
||||
|
||||
# A deadline beyond the reported slots falls back to the final SoC.
|
||||
optimization._ev_soc_deadline_slot = 99
|
||||
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
|
||||
|
||||
|
||||
def test_ev_deadline_charges_before_departure(config_eos: ConfigEOS):
|
||||
"""The EV reaches its target before the deadline even when energy is cheaper later."""
|
||||
hours = 24
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": hours},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"individuals": 100,
|
||||
"generations": 40,
|
||||
"tail_horizon_hours": 0,
|
||||
"horizon_hours": hours,
|
||||
"interval_sec": 3600,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
CacheEnergyManagementStore().clear()
|
||||
|
||||
parameters = _ev_deadline_parameters(hours, min_soc_max_duration_h=6)
|
||||
solution = GeneticOptimization(fixed_seed=42).optimize_ems(
|
||||
parameters=parameters, start_hour=0, ngen=40
|
||||
)
|
||||
|
||||
soc_per_hour = solution.result.ev_soc_per_hour
|
||||
# Slot 6 is the first slot at or after the deadline, so its start-of-slot SoC
|
||||
# is what the target is checked against.
|
||||
assert soc_per_hour[6] >= 60.0
|
||||
|
||||
|
||||
def _terminal_value_run(
|
||||
config_eos: ConfigEOS, mode: str, prices: Optional[list[float]] = None
|
||||
) -> GeneticSolution:
|
||||
"""48 h with expensive energy and two dirt-cheap slots at the very end.
|
||||
|
||||
Charging in those last slots only pays off when the stored energy keeps a
|
||||
value beyond the horizon.
|
||||
"""
|
||||
hours = 48
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": hours},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"individuals": 80,
|
||||
"generations": 20,
|
||||
"tail_horizon_hours": 0,
|
||||
"horizon_hours": hours,
|
||||
"interval_sec": 3600,
|
||||
"terminal_value_mode": mode,
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
CacheEnergyManagementStore().clear()
|
||||
|
||||
if prices is None:
|
||||
prices = [0.0004] * (hours - 2) + [0.00002] * 2
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
"strompreis_euro_pro_wh": prices,
|
||||
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [200.0] * hours,
|
||||
},
|
||||
pv_battery={
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 10000,
|
||||
"initial_soc_percentage": 20,
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100,
|
||||
"charging_efficiency": 1.0,
|
||||
"discharging_efficiency": 1.0,
|
||||
"max_charge_power_w": 5000,
|
||||
},
|
||||
inverter={
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 10000,
|
||||
"battery_id": "battery1",
|
||||
"ac_to_dc_efficiency": 1.0,
|
||||
"dc_to_ac_efficiency": 1.0,
|
||||
"max_ac_charge_power_w": 5000,
|
||||
},
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
return GeneticOptimization(fixed_seed=7).optimize_ems(
|
||||
parameters=parameters, start_hour=0, ngen=20
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_value_auto_keeps_energy_that_fixed_zero_throws_away(config_eos: ConfigEOS):
|
||||
"""AUTO values the energy left in the battery, a fixed zero does not."""
|
||||
auto = _terminal_value_run(config_eos, "AUTO")
|
||||
fixed = _terminal_value_run(config_eos, "FIXED")
|
||||
|
||||
assert auto.terminal_value is not None
|
||||
assert auto.terminal_value.mode == "AUTO"
|
||||
assert auto.terminal_value.curve is not None
|
||||
assert auto.terminal_value.credited_euro > 0.0
|
||||
|
||||
assert fixed.terminal_value is not None
|
||||
assert fixed.terminal_value.mode == "FIXED"
|
||||
assert fixed.terminal_value.credited_euro == 0.0
|
||||
|
||||
# The cheap slots at the end are only worth using with a terminal value.
|
||||
assert auto.result.battery_soc_per_hour[-1] > fixed.result.battery_soc_per_hour[-1]
|
||||
|
||||
|
||||
def test_terminal_value_curve_is_concave_and_reported(config_eos: ConfigEOS):
|
||||
"""The reported curve is what the credit was read from."""
|
||||
solution = _terminal_value_run(config_eos, "AUTO")
|
||||
assert solution.terminal_value is not None
|
||||
curve = solution.terminal_value.curve
|
||||
assert curve is not None
|
||||
|
||||
assert curve.window_slots == 24
|
||||
assert len(curve.energy_wh) == len(curve.value_euro)
|
||||
assert len(curve.marginal_euro_per_kwh) == len(curve.energy_wh) - 1
|
||||
marginals = curve.marginal_euro_per_kwh
|
||||
assert all(a >= b for a, b in zip(marginals, marginals[1:]))
|
||||
|
||||
# The credit is the curve evaluated at the energy left in the battery.
|
||||
expected = curve.value(solution.terminal_value.battery_energy_wh)
|
||||
assert solution.terminal_value.credited_euro == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_terminal_value_reports_why_it_fell_back_to_fixed(config_eos: ConfigEOS):
|
||||
"""AUTO without any prices cannot build a curve - and has to say so.
|
||||
|
||||
A request whose price forecast is all zeros used to be indistinguishable
|
||||
from a run configured for FIXED.
|
||||
"""
|
||||
hours = 48
|
||||
solution = _terminal_value_run(config_eos, "AUTO", prices=[0.0] * hours)
|
||||
|
||||
assert solution.terminal_value is not None
|
||||
assert solution.terminal_value.mode == "FIXED"
|
||||
assert solution.terminal_value.curve is None
|
||||
assert solution.terminal_value.reason is not None
|
||||
assert "no priced residual load" in solution.terminal_value.reason
|
||||
|
||||
configured = _terminal_value_run(config_eos, "FIXED")
|
||||
assert configured.terminal_value is not None
|
||||
assert configured.terminal_value.reason == "terminal_value_mode is FIXED"
|
||||
@@ -0,0 +1,528 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.devices.genetic.battery import (
|
||||
Battery,
|
||||
ElectricVehicleParameters,
|
||||
SolarPanelBatteryParameters,
|
||||
)
|
||||
from akkudoktoreos.devices.genetic.homeappliance import (
|
||||
HomeAppliance,
|
||||
HomeApplianceParameters,
|
||||
)
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
)
|
||||
|
||||
start_hour = 1
|
||||
|
||||
|
||||
# Example initialization of necessary components
|
||||
@pytest.fixture
|
||||
def genetic_simulation(config_eos) -> GeneticSimulation:
|
||||
"""Fixture to create an EnergyManagement instance with given test parameters."""
|
||||
# Assure configuration holds the correct values
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {"hours": 24, "genetic": {"tail_horizon_hours": 0}},
|
||||
}
|
||||
)
|
||||
assert config_eos.prediction.hours == 48
|
||||
assert config_eos.optimization.genetic.horizon_hours == 24
|
||||
|
||||
# Initialize the battery and the inverter
|
||||
akku = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=5000,
|
||||
initial_soc_percentage=80,
|
||||
min_soc_percentage=10,
|
||||
),
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
akku.reset()
|
||||
|
||||
inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1", max_power_wh=10000, battery_id=akku.parameters.device_id
|
||||
),
|
||||
battery=akku,
|
||||
)
|
||||
|
||||
# Flexible consumer (fixed start at slot 2 for this deterministic test)
|
||||
home_appliance = HomeAppliance(
|
||||
HomeApplianceParameters(
|
||||
device_id="dishwasher1",
|
||||
consumption_wh=2000,
|
||||
duration_h=2,
|
||||
time_windows=None,
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
home_appliance.build_load_curve([2])
|
||||
|
||||
# Example initialization of electric car battery
|
||||
eauto = Battery(
|
||||
ElectricVehicleParameters(
|
||||
device_id="ev1", capacity_wh=26400, initial_soc_percentage=10, min_soc_percentage=10
|
||||
),
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
eauto.set_charge_per_hour(np.full(config_eos.prediction.hours, 1))
|
||||
|
||||
# Parameters based on previous example data
|
||||
pv_prognose_wh = [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
8.05,
|
||||
352.91,
|
||||
728.51,
|
||||
930.28,
|
||||
1043.25,
|
||||
1106.74,
|
||||
1161.69,
|
||||
6018.82,
|
||||
5519.07,
|
||||
3969.88,
|
||||
3017.96,
|
||||
1943.07,
|
||||
1007.17,
|
||||
319.67,
|
||||
7.88,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5.04,
|
||||
335.59,
|
||||
705.32,
|
||||
1121.12,
|
||||
1604.79,
|
||||
2157.38,
|
||||
1433.25,
|
||||
5718.49,
|
||||
4553.96,
|
||||
3027.55,
|
||||
2574.46,
|
||||
1720.4,
|
||||
963.4,
|
||||
383.3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]
|
||||
|
||||
strompreis_euro_pro_wh = [
|
||||
0.0003384,
|
||||
0.0003318,
|
||||
0.0003284,
|
||||
0.0003283,
|
||||
0.0003289,
|
||||
0.0003334,
|
||||
0.0003290,
|
||||
0.0003302,
|
||||
0.0003042,
|
||||
0.0002430,
|
||||
0.0002280,
|
||||
0.0002212,
|
||||
0.0002093,
|
||||
0.0001879,
|
||||
0.0001838,
|
||||
0.0002004,
|
||||
0.0002198,
|
||||
0.0002270,
|
||||
0.0002997,
|
||||
0.0003195,
|
||||
0.0003081,
|
||||
0.0002969,
|
||||
0.0002921,
|
||||
0.0002780,
|
||||
0.0003384,
|
||||
0.0003318,
|
||||
0.0003284,
|
||||
0.0003283,
|
||||
0.0003289,
|
||||
0.0003334,
|
||||
0.0003290,
|
||||
0.0003302,
|
||||
0.0003042,
|
||||
0.0002430,
|
||||
0.0002280,
|
||||
0.0002212,
|
||||
0.0002093,
|
||||
0.0001879,
|
||||
0.0001838,
|
||||
0.0002004,
|
||||
0.0002198,
|
||||
0.0002270,
|
||||
0.0002997,
|
||||
0.0003195,
|
||||
0.0003081,
|
||||
0.0002969,
|
||||
0.0002921,
|
||||
0.0002780,
|
||||
]
|
||||
|
||||
einspeiseverguetung_euro_pro_wh = 0.00007
|
||||
preis_euro_pro_wh_akku = 0.0001
|
||||
|
||||
gesamtlast = [
|
||||
676.71,
|
||||
876.19,
|
||||
527.13,
|
||||
468.88,
|
||||
531.38,
|
||||
517.95,
|
||||
483.15,
|
||||
472.28,
|
||||
1011.68,
|
||||
995.00,
|
||||
1053.07,
|
||||
1063.91,
|
||||
1320.56,
|
||||
1132.03,
|
||||
1163.67,
|
||||
1176.82,
|
||||
1216.22,
|
||||
1103.78,
|
||||
1129.12,
|
||||
1178.71,
|
||||
1050.98,
|
||||
988.56,
|
||||
912.38,
|
||||
704.61,
|
||||
516.37,
|
||||
868.05,
|
||||
694.34,
|
||||
608.79,
|
||||
556.31,
|
||||
488.89,
|
||||
506.91,
|
||||
804.89,
|
||||
1141.98,
|
||||
1056.97,
|
||||
992.46,
|
||||
1155.99,
|
||||
827.01,
|
||||
1257.98,
|
||||
1232.67,
|
||||
871.26,
|
||||
860.88,
|
||||
1158.03,
|
||||
1222.72,
|
||||
1221.04,
|
||||
949.99,
|
||||
987.01,
|
||||
733.99,
|
||||
592.97,
|
||||
]
|
||||
|
||||
# Initialize the energy management system with the respective parameters
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=pv_prognose_wh,
|
||||
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
|
||||
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
|
||||
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
|
||||
gesamtlast=gesamtlast,
|
||||
)
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
ev=eauto,
|
||||
home_appliances=[home_appliance],
|
||||
)
|
||||
|
||||
# Init for test
|
||||
assert simulation.ac_charge_hours is not None
|
||||
assert simulation.dc_charge_hours is not None
|
||||
assert simulation.bat_discharge_hours is not None
|
||||
assert simulation.bat_grid_export_hours is not None
|
||||
assert simulation.ev_charge_hours is not None
|
||||
simulation.ac_charge_hours[start_hour] = 1.0
|
||||
simulation.dc_charge_hours[start_hour] = 1.0
|
||||
simulation.bat_discharge_hours[start_hour] = 1.0
|
||||
simulation.ev_charge_hours[start_hour] = 1.0
|
||||
|
||||
return simulation
|
||||
|
||||
|
||||
def test_ev_charging_uses_raw_input_energy_for_load_and_grid(config_eos):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 1},
|
||||
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": 1}},
|
||||
}
|
||||
)
|
||||
ev = Battery(
|
||||
ElectricVehicleParameters(
|
||||
device_id="ev1",
|
||||
capacity_wh=1000,
|
||||
charging_efficiency=0.8,
|
||||
max_charge_power_w=100,
|
||||
initial_soc_percentage=0,
|
||||
min_soc_percentage=0,
|
||||
),
|
||||
prediction_hours=1,
|
||||
)
|
||||
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=1000.0))
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[0.0],
|
||||
strompreis_euro_pro_wh=[0.001],
|
||||
einspeiseverguetung_euro_pro_wh=[0.0],
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0],
|
||||
)
|
||||
),
|
||||
optimization_hours=1,
|
||||
prediction_hours=1,
|
||||
inverter=inverter,
|
||||
ev=ev,
|
||||
)
|
||||
simulation.ev_charge_hours = np.array([1.0])
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Last_Wh_pro_Stunde"][0] == pytest.approx(100.0)
|
||||
assert result["Netzbezug_Wh_pro_Stunde"][0] == pytest.approx(100.0)
|
||||
assert result["Kosten_Euro_pro_Stunde"][0] == pytest.approx(0.1)
|
||||
assert result["Verluste_Pro_Stunde"][0] == pytest.approx(20.0)
|
||||
assert ev.current_soc_percentage() == pytest.approx(8.0)
|
||||
|
||||
|
||||
def test_direct_marketing_curtails_negative_feed_in(config_eos, monkeypatch):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 2},
|
||||
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": 2}},
|
||||
}
|
||||
)
|
||||
|
||||
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=1000.0))
|
||||
monkeypatch.setattr(
|
||||
inverter.self_consumption_predictor,
|
||||
"calculate_expected_direct_consumption",
|
||||
Mock(side_effect=min),
|
||||
)
|
||||
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[500.0, 500.0],
|
||||
strompreis_euro_pro_wh=[-0.0001, -0.0001],
|
||||
einspeiseverguetung_euro_pro_wh=[-0.0001, -0.0001],
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0, 0.0],
|
||||
)
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
direct_marketing_enabled=True,
|
||||
)
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == 0.0
|
||||
assert result["Einnahmen_Euro_pro_Stunde"][0] == 0.0
|
||||
assert result["Verluste_Pro_Stunde"][0] == pytest.approx(500.0)
|
||||
|
||||
|
||||
def _direct_marketing_battery_export_simulation(
|
||||
config_eos,
|
||||
levelized_cost_of_storage_kwh: float = 0.0,
|
||||
dc_to_ac_efficiency: float = 1.0,
|
||||
) -> GeneticSimulation:
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 2},
|
||||
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": 2}},
|
||||
}
|
||||
)
|
||||
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=1000,
|
||||
initial_soc_percentage=100,
|
||||
min_soc_percentage=0,
|
||||
charging_efficiency=1.0,
|
||||
discharging_efficiency=1.0,
|
||||
levelized_cost_of_storage_kwh=levelized_cost_of_storage_kwh,
|
||||
max_charge_power_w=500,
|
||||
),
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
max_power_wh=500.0,
|
||||
battery_id=battery.parameters.device_id,
|
||||
dc_to_ac_efficiency=dc_to_ac_efficiency,
|
||||
),
|
||||
battery=battery,
|
||||
)
|
||||
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[0.0, 0.0],
|
||||
strompreis_euro_pro_wh=[0.0, 0.0],
|
||||
einspeiseverguetung_euro_pro_wh=[0.0002, 0.0002],
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0, 0.0],
|
||||
)
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
direct_marketing_enabled=True,
|
||||
)
|
||||
return simulation
|
||||
|
||||
|
||||
def test_direct_marketing_discharge_allowed_does_not_export_battery(config_eos):
|
||||
simulation = _direct_marketing_battery_export_simulation(config_eos)
|
||||
assert simulation.bat_discharge_hours is not None
|
||||
simulation.bat_discharge_hours[0] = 1
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == 0.0
|
||||
assert simulation.battery is not None
|
||||
assert simulation.battery.current_soc_percentage() == 100.0
|
||||
|
||||
|
||||
def test_direct_marketing_battery_grid_export_uses_separate_signal(config_eos):
|
||||
simulation = _direct_marketing_battery_export_simulation(config_eos)
|
||||
assert simulation.bat_grid_export_hours is not None
|
||||
simulation.bat_grid_export_hours[0] = 1
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == pytest.approx(500.0)
|
||||
assert result["Einnahmen_Euro_pro_Stunde"][0] == pytest.approx(0.1)
|
||||
assert simulation.battery is not None
|
||||
assert simulation.battery.current_soc_percentage() == 50.0
|
||||
|
||||
|
||||
def test_direct_marketing_grid_export_rate_limits_exported_energy(config_eos):
|
||||
"""A partial export level exports that share of the rated discharge power."""
|
||||
simulation = _direct_marketing_battery_export_simulation(config_eos)
|
||||
assert simulation.bat_grid_export_hours is not None
|
||||
# 500 W rated discharge power over a one hour slot -> 500 Wh at rate 1.0.
|
||||
simulation.bat_grid_export_hours[0] = 0.5
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == pytest.approx(250.0)
|
||||
assert simulation.battery is not None
|
||||
assert simulation.battery.current_soc_percentage() == 75.0
|
||||
|
||||
|
||||
def test_battery_lcos_is_charged_once_on_delivered_energy(config_eos):
|
||||
simulation = _direct_marketing_battery_export_simulation(
|
||||
config_eos,
|
||||
levelized_cost_of_storage_kwh=0.12,
|
||||
dc_to_ac_efficiency=0.8,
|
||||
)
|
||||
assert simulation.bat_grid_export_hours is not None
|
||||
simulation.bat_grid_export_hours[0] = 1
|
||||
|
||||
result = simulation.simulate(start_hour=0)
|
||||
|
||||
# The battery delivers 500 Wh DC, so LCOS is 0.5 kWh * 0.12 EUR/kWh
|
||||
# = 0.06 EUR exactly once. After the 80% inverter, 400 Wh AC reaches
|
||||
# the grid and earns 400 Wh * 0.0002 EUR/Wh = 0.08 EUR.
|
||||
assert result["Kosten_Euro_pro_Stunde"][0] == pytest.approx(0.06)
|
||||
assert result["Gesamtkosten_Euro"] == pytest.approx(0.06)
|
||||
assert result["Einnahmen_Euro_pro_Stunde"][0] == pytest.approx(0.08)
|
||||
assert result["Gesamtbilanz_Euro"] == pytest.approx(-0.02)
|
||||
|
||||
|
||||
def test_disabled_ac_charging_clears_the_reported_plan(config_eos):
|
||||
"""With AC charging off the reported plan must not keep charge commands.
|
||||
|
||||
The simulation ignores the AC charge genes when the inverter forbids grid
|
||||
charging. The solution is read back from the same array, so a controller
|
||||
acting on it would grid-charge the battery although no such charge was ever
|
||||
simulated or paid for.
|
||||
"""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 2},
|
||||
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": 2}},
|
||||
}
|
||||
)
|
||||
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=10000,
|
||||
initial_soc_percentage=50,
|
||||
min_soc_percentage=0,
|
||||
charging_efficiency=1.0,
|
||||
discharging_efficiency=1.0,
|
||||
max_charge_power_w=5000,
|
||||
),
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
)
|
||||
inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
max_power_wh=5000.0,
|
||||
battery_id=battery.parameters.device_id,
|
||||
max_ac_charge_power_w=0, # Netzladen deaktiviert
|
||||
),
|
||||
battery=battery,
|
||||
)
|
||||
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[0.0, 0.0],
|
||||
strompreis_euro_pro_wh=[0.0003, 0.0003],
|
||||
einspeiseverguetung_euro_pro_wh=[0.0001, 0.0001],
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0, 0.0],
|
||||
)
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=config_eos.prediction.hours,
|
||||
inverter=inverter,
|
||||
)
|
||||
simulation.ac_charge_hours = np.array([0.8, 0.0])
|
||||
|
||||
soc_before = battery.current_soc_percentage()
|
||||
simulation.simulate(start_hour=0)
|
||||
|
||||
# Nothing was charged ...
|
||||
assert battery.current_soc_percentage() == pytest.approx(soc_before)
|
||||
# ... and the plan says so.
|
||||
assert simulation.ac_charge_hours is not None
|
||||
assert list(simulation.ac_charge_hours) == [0.0, 0.0]
|
||||
@@ -0,0 +1,188 @@
|
||||
# ruff: noqa: S101
|
||||
|
||||
import numpy as np
|
||||
|
||||
from akkudoktoreos.devices.devicesabc import BatteryOperationMode
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
|
||||
|
||||
def test_battery_discharge_allowed_remains_local_load_mode(config_eos):
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
solution = GeneticSolution
|
||||
|
||||
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
|
||||
ac_charge=0.0,
|
||||
dc_charge=0.0,
|
||||
discharge_allowed=True,
|
||||
)
|
||||
|
||||
assert operation_mode == BatteryOperationMode.PEAK_SHAVING
|
||||
assert operation_mode_factor == 1.0
|
||||
|
||||
|
||||
def test_battery_grid_export_signal_maps_to_grid_support_export(config_eos):
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
solution = GeneticSolution
|
||||
|
||||
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
|
||||
ac_charge=0.0,
|
||||
dc_charge=0.0,
|
||||
discharge_allowed=False,
|
||||
battery_grid_export_allowed=True,
|
||||
)
|
||||
|
||||
assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT
|
||||
assert operation_mode_factor == 1.0
|
||||
|
||||
|
||||
def test_decode_charge_discharge_has_separate_battery_grid_export_state():
|
||||
optimization = GeneticOptimization()
|
||||
optimization.bat_possible_charge_values = [1.0]
|
||||
optimization.optimize_dc_charge = True
|
||||
optimization.optimize_battery_grid_export = True
|
||||
|
||||
ac_charge, dc_charge, discharge, battery_grid_export = optimization.decode_charge_discharge(
|
||||
np.array([5])
|
||||
)
|
||||
|
||||
assert ac_charge.tolist() == [0.0]
|
||||
assert dc_charge.tolist() == [0]
|
||||
assert discharge.tolist() == [0]
|
||||
assert battery_grid_export.tolist() == [1]
|
||||
|
||||
|
||||
def test_decode_charge_discharge_has_self_consumption_state_after_legacy_export():
|
||||
optimization = GeneticOptimization()
|
||||
optimization.bat_possible_charge_values = [1.0]
|
||||
optimization.optimize_dc_charge = True
|
||||
optimization.optimize_battery_grid_export = True
|
||||
|
||||
layout = optimization._battery_state_layout()
|
||||
ac_charge, dc_charge, discharge, battery_grid_export = optimization.decode_charge_discharge(
|
||||
np.array([6])
|
||||
)
|
||||
|
||||
assert layout.total_states == 7
|
||||
assert layout.grid_export_state == 5
|
||||
assert layout.self_consumption_state == 6
|
||||
assert ac_charge.tolist() == [0.0]
|
||||
assert dc_charge.tolist() == [1]
|
||||
assert discharge.tolist() == [1]
|
||||
assert battery_grid_export.tolist() == [0]
|
||||
|
||||
|
||||
def test_graded_grid_export_states_decode_to_rates():
|
||||
"""Each configured export rate gets its own state; state 5 stays full power."""
|
||||
optimization = GeneticOptimization()
|
||||
optimization.bat_possible_charge_values = [1.0]
|
||||
optimization.bat_possible_grid_export_values = [1.0, 0.5, 0.25]
|
||||
optimization.optimize_dc_charge = True
|
||||
optimization.optimize_battery_grid_export = True
|
||||
|
||||
layout = optimization._battery_state_layout()
|
||||
|
||||
assert layout.grid_export_states == (5, 6, 7)
|
||||
# The full-power state keeps its index, so existing seeds stay valid.
|
||||
assert layout.grid_export_state == 5
|
||||
assert layout.self_consumption_state == 8
|
||||
assert layout.total_states == 9
|
||||
|
||||
_, _, _, battery_grid_export = optimization.decode_charge_discharge(np.array([0, 5, 6, 7]))
|
||||
assert battery_grid_export.tolist() == [0.0, 1.0, 0.5, 0.25]
|
||||
|
||||
|
||||
def test_single_export_rate_keeps_all_or_nothing_layout():
|
||||
"""Without configured rates the state space is the one from before grading."""
|
||||
optimization = GeneticOptimization()
|
||||
optimization.bat_possible_charge_values = [1.0]
|
||||
optimization.optimize_dc_charge = True
|
||||
optimization.optimize_battery_grid_export = True
|
||||
|
||||
layout = optimization._battery_state_layout()
|
||||
|
||||
assert layout.grid_export_states == (5,)
|
||||
assert layout.total_states == 7
|
||||
|
||||
|
||||
def test_battery_grid_export_factor_becomes_operation_factor(config_eos):
|
||||
"""A partial export level is reported as the GRID_SUPPORT_EXPORT factor."""
|
||||
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||
solution = GeneticSolution
|
||||
|
||||
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
|
||||
ac_charge=0.0,
|
||||
dc_charge=0.0,
|
||||
discharge_allowed=False,
|
||||
battery_grid_export_allowed=True,
|
||||
battery_grid_export_factor=0.25,
|
||||
)
|
||||
|
||||
assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT
|
||||
assert operation_mode_factor == 0.25
|
||||
|
||||
|
||||
def test_disjoint_cycle_masks_keep_feasible_non_deadline_order(config_eos):
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"interval_sec": 900,
|
||||
"horizon_hours": 2,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
get_ems(init=True).set_start_datetime(
|
||||
to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
)
|
||||
params = GeneticOptimizationParameters.model_validate(
|
||||
{
|
||||
"ems": {
|
||||
"pv_forecast_wh": [0.0] * 8,
|
||||
"total_load": [0.0] * 8,
|
||||
"electricity_price_per_wh": [0.0003] * 8,
|
||||
"feed_in_tariff_per_wh": 0.0,
|
||||
"price_per_wh_battery": 0.0,
|
||||
},
|
||||
"forecast_interval_seconds": 900,
|
||||
"pv_battery": None,
|
||||
"ev": None,
|
||||
"inverter": {"device_id": "inv", "max_power_wh": 1000},
|
||||
"home_appliances": [
|
||||
{
|
||||
"device_id": "washer",
|
||||
"num_cycles": 2,
|
||||
"load_profile_power_w": [1000.0, 1000.0],
|
||||
"load_profile_interval_seconds": 900,
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "00:00", "duration": "30 minutes", "value": 1},
|
||||
{"start_time": "01:15", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
optimizer = GeneticOptimization(fixed_seed=42)
|
||||
solution = optimizer.optimize_ems(params, ngen=1, individuals=6)
|
||||
assert [(item.hour, item.minute) for item in solution.appliance_starts["washer"]] == [
|
||||
(0, 0),
|
||||
(1, 0),
|
||||
]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == 1000.0
|
||||
# Even a candidate choosing the second disconnected window repairs to the
|
||||
# feasible order, without dropping a configured run or crossing its mask.
|
||||
genes = [0, 1]
|
||||
assert optimizer._decode_appliance_starts(genes) == {0: [0, 4]}
|
||||
assert genes == [0, 0]
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Economic tail scenarios and hard control/forecast boundaries."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import SettingsEOSDefaults
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.tailvalue import build_tail_value_curve
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueCurve
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
def devices(power=1000, efficiency=1.0, lcos=0, ac_limit=None, export_power=5000):
|
||||
bat = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=1000,
|
||||
max_charge_power_w=power,
|
||||
charging_efficiency=efficiency,
|
||||
discharging_efficiency=efficiency,
|
||||
initial_soc_percentage=50,
|
||||
levelized_cost_of_storage_kwh=lcos,
|
||||
charge_rates=[0, 0.5, 1],
|
||||
),
|
||||
prediction_hours=1,
|
||||
)
|
||||
inv = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
battery_id="battery1",
|
||||
max_power_wh=export_power,
|
||||
dc_to_ac_efficiency=1,
|
||||
ac_to_dc_efficiency=1,
|
||||
max_ac_charge_power_w=ac_limit,
|
||||
),
|
||||
battery=bat,
|
||||
)
|
||||
return bat, inv
|
||||
|
||||
|
||||
def curve(
|
||||
prices=(-0.1, 0.3),
|
||||
tariffs=(0, 0.3),
|
||||
direct=True,
|
||||
continuation=None,
|
||||
load=None,
|
||||
pv=None,
|
||||
**kwargs,
|
||||
):
|
||||
bat, inv = devices(**kwargs)
|
||||
return build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array(prices) / 1000,
|
||||
feed_in_euro_per_wh=np.array(tariffs) / 1000,
|
||||
load_wh=np.zeros(len(prices)) if load is None else np.array(load),
|
||||
pv_wh=np.zeros(len(prices)) if pv is None else np.array(pv),
|
||||
continuation=continuation or TerminalValueCurve(),
|
||||
charge_rates=[0.5, 1],
|
||||
export_rates=[1],
|
||||
direct_marketing=direct,
|
||||
)
|
||||
|
||||
|
||||
def test_headroom_has_value_and_empty_state_can_earn():
|
||||
c = curve()
|
||||
assert c.value(0) == pytest.approx(0.4)
|
||||
tail, continuation = c.component_values(0)
|
||||
assert tail == pytest.approx(0.4)
|
||||
assert continuation == pytest.approx(0.0)
|
||||
assert c.value(0) == pytest.approx(tail + continuation)
|
||||
assert c.value(500) > c.value(1000)
|
||||
assert any(v < 0 for v in c.marginal_euro_per_kwh)
|
||||
|
||||
|
||||
def test_chronology_changes_arbitrage():
|
||||
forward = curve()
|
||||
reverse = curve(prices=(0.3, -0.1), tariffs=(0.3, 0))
|
||||
assert forward.value(0) > reverse.value(0)
|
||||
|
||||
|
||||
def test_discharge_and_ac_power_limits():
|
||||
limited = curve(prices=(1,), tariffs=(1,), power=100)
|
||||
assert limited.value(1000) == pytest.approx(0.1)
|
||||
limited_ac = curve(ac_limit=100)
|
||||
assert limited_ac.value(0) == pytest.approx(0.04)
|
||||
limited_inverter = curve(prices=(1,), tariffs=(1,), export_power=50)
|
||||
assert limited_inverter.value(1000) == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_losses_and_lcos_reduce_arbitrage():
|
||||
ideal = curve(prices=(0.1, 0.3))
|
||||
lossy = curve(prices=(0.1, 0.3), efficiency=0.8)
|
||||
assert 0 < lossy.value(0) < ideal.value(0)
|
||||
assert curve(prices=(0.1, 0.3), lcos=0.25).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_no_battery_export_without_permission():
|
||||
assert curve(prices=(0.1, 0.3), direct=False).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_pv_surplus_can_be_stored_for_local_load():
|
||||
c = curve(prices=(0.2, 0.3), tariffs=(0, 0), pv=[1000, 0], load=[0, 1000], direct=False)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
# Without PV the same empty battery must buy energy to serve the load.
|
||||
assert curve(prices=(0.2, 0.3), tariffs=(0, 0), load=[0, 1000], direct=False).value(
|
||||
0
|
||||
) < c.value(0)
|
||||
|
||||
|
||||
def test_continuation_survives_tail_end():
|
||||
continuation = TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.2])
|
||||
c = curve(prices=(0.5,), tariffs=(0,), continuation=continuation)
|
||||
assert c.value(1000) == pytest.approx(0.2)
|
||||
tail, continuation_credit = c.component_values(1000)
|
||||
assert tail == pytest.approx(0.0)
|
||||
assert continuation_credit == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_tail_diagnostic_plan_explains_the_selected_path():
|
||||
c = curve()
|
||||
plan = c.diagnostic_plan(0, control_horizon_hours=24)
|
||||
assert len(plan) == 2
|
||||
assert plan[0].hour_from_start == 24
|
||||
assert plan[0].action == "GRID_CHARGE"
|
||||
assert plan[0].soc_end_percentage > plan[0].soc_start_percentage
|
||||
assert plan[0].grid_import_wh > 0
|
||||
assert plan[1].action == "BATTERY_EXPORT"
|
||||
assert plan[1].soc_end_percentage < plan[1].soc_start_percentage
|
||||
assert plan[1].grid_export_wh > 0
|
||||
assert sum(slot.slot_value_euro for slot in plan) == pytest.approx(c.value(0))
|
||||
|
||||
|
||||
def test_central_config_invariant():
|
||||
# Only the control horizon is mandatory. A prediction horizon that cannot
|
||||
# cover the requested tail shortens the tail instead of failing the run,
|
||||
# so existing configurations keep starting after an upgrade.
|
||||
short = SettingsEOSDefaults.model_validate(
|
||||
dict(
|
||||
prediction={"hours": 48},
|
||||
optimization={"genetic": {"horizon_hours": 24, "tail_horizon_hours": 48}},
|
||||
)
|
||||
)
|
||||
assert short.prediction.hours == 48
|
||||
assert short.optimization.genetic.tail_horizon_hours == 48
|
||||
|
||||
# A control horizon the forecast cannot serve is not rejected here either -
|
||||
# prediction.hours also serves callers that never optimize. The optimizer
|
||||
# rejects the run itself, naming the series that ran out.
|
||||
undersized = SettingsEOSDefaults.model_validate(
|
||||
dict(
|
||||
prediction={"hours": 48},
|
||||
optimization={"genetic": {"horizon_hours": 72, "tail_horizon_hours": 0}},
|
||||
)
|
||||
)
|
||||
assert undersized.optimization.genetic.horizon_hours == 72
|
||||
|
||||
settings = SettingsEOSDefaults()
|
||||
assert settings.prediction.hours == 48
|
||||
assert settings.optimization.genetic.tail_horizon_hours == 48
|
||||
|
||||
|
||||
def setup_run(config, interval=3600, start_hour=0, hours=72, prediction_hours=72):
|
||||
config.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": prediction_hours},
|
||||
"optimization": {
|
||||
"genetic": {"horizon_hours": 24, "tail_horizon_hours": 48, "interval_sec": interval}
|
||||
},
|
||||
"feedintariff": {"direct_marketing_enabled": True},
|
||||
}
|
||||
)
|
||||
ems = get_ems(init=True)
|
||||
ems.set_start_datetime(to_datetime("2026-09-05T00:00:00").set(hour=start_hour))
|
||||
bat, inv = devices()
|
||||
params = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
"gesamtlast": [0.0] * hours,
|
||||
"strompreis_euro_pro_wh": [0.0002] * hours,
|
||||
"einspeiseverguetung_euro_pro_wh": [0.0001] * hours,
|
||||
"preis_euro_pro_wh_akku": 0,
|
||||
},
|
||||
pv_battery=bat.parameters,
|
||||
inverter=inv.parameters,
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
return GeneticOptimization(fixed_seed=42), params
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interval", [3600, 900])
|
||||
@pytest.mark.parametrize("start_hour", [0, 10])
|
||||
@pytest.mark.asyncio
|
||||
async def test_genome_output_and_final_control_state(config_eos, interval, start_hour):
|
||||
opt, params = setup_run(config_eos, interval, start_hour, hours=72 + start_hour)
|
||||
|
||||
def choose(*args, **kwargs):
|
||||
# Discharge only in the last control slot. Its POST-slot SOC is credited.
|
||||
genome = opt.create_individual()
|
||||
genome[:] = [0] * opt.control_end_slot
|
||||
genome[-1] = opt._battery_state_layout().grid_export_state
|
||||
assert len(genome) == 24 * (3600 // interval)
|
||||
return genome, {}
|
||||
|
||||
with (
|
||||
patch.object(opt, "optimize", side_effect=choose),
|
||||
patch(
|
||||
"akkudoktoreos.optimization.genetic.genetic.build_tail_value_curve",
|
||||
wraps=build_tail_value_curve,
|
||||
) as builder,
|
||||
):
|
||||
result = opt.optimize_ems(params)
|
||||
assert builder.call_count == 1
|
||||
assert len(result.ac_charge) == opt.control_slots
|
||||
assert len(result.dc_charge) == opt.control_slots
|
||||
assert len(result.discharge_allowed) == opt.control_slots
|
||||
assert len(result.battery_grid_export_factor) == opt.control_slots
|
||||
assert len(result.result.Kosten_Euro_pro_Stunde) == opt.control_slots
|
||||
assert result.terminal_value.mode == "TAIL"
|
||||
assert result.terminal_value.battery_energy_wh == pytest.approx(
|
||||
max(500 - 1000 * opt.slot_duration_h, 0)
|
||||
)
|
||||
assert result.terminal_value.effective_tail_hours == 48
|
||||
assert result.terminal_value.credited_euro == pytest.approx(
|
||||
result.terminal_value.tail_operating_euro + result.terminal_value.continuation_value_euro
|
||||
)
|
||||
assert result.terminal_value.continuation_curve is not None
|
||||
assert result.terminal_value.tail_diagnostics is not None
|
||||
assert result.terminal_value.tail_diagnostics.slots == 48 * (3600 // interval)
|
||||
assert result.terminal_value.tail_diagnostics.soc_grid_points == 101
|
||||
assert len(result.terminal_value.tail_plan) == result.terminal_value.tail_diagnostics.slots
|
||||
assert result.terminal_value.tail_plan[0].hour_from_start == 24
|
||||
assert len(result.terminal_value.curve.operating_value_euro) == 101
|
||||
assert len(result.terminal_value.curve.continuation_value_euro) == 101
|
||||
assert len((await result.optimization_solution()).solution.to_dataframe()) == opt.control_slots
|
||||
|
||||
|
||||
def test_short_tail_is_reported(config_eos, caplog):
|
||||
opt, params = setup_run(config_eos, hours=52)
|
||||
with patch.object(
|
||||
opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_end_slot, {})
|
||||
):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.effective_tail_hours == 28
|
||||
assert "Tail forecast shortened" in caplog.text
|
||||
assert result.terminal_value.reason
|
||||
|
||||
|
||||
def test_missing_control_is_rejected(config_eos):
|
||||
opt, params = setup_run(config_eos, hours=23)
|
||||
with pytest.raises(ValueError, match="Incomplete control forecast"):
|
||||
opt.optimize_ems(params)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_values_are_not_extrapolated():
|
||||
from types import SimpleNamespace
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
series = pd.Series([1.0, 2.0], index=pd.date_range(start=start, periods=2, freq="h"))
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
provider = SimpleNamespace(key_to_raw_series=AsyncMock(return_value=series))
|
||||
result = await bounded_forecast_array(
|
||||
provider,
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=3),
|
||||
interval=to_duration("15 minutes"),
|
||||
)
|
||||
assert result[:8].tolist() == [1.0] * 4 + [2.0] * 4
|
||||
assert np.isnan(result[8:]).all()
|
||||
|
||||
|
||||
def test_future_opportunity_changes_optimal_control_soc(config_eos):
|
||||
final_energy = []
|
||||
for negative_price in (0.5, -1.0):
|
||||
opt, params = setup_run(config_eos, hours=3)
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"optimization": {"genetic": {"horizon_hours": 1, "tail_horizon_hours": 2}}}
|
||||
)
|
||||
params.ems.electricity_price_per_wh = [0.0005, negative_price / 1000, 0.0003]
|
||||
params.ems.feed_in_tariff_per_wh = [0.00002, 0, 0.0003]
|
||||
result = opt.optimize_ems(params, ngen=3, individuals=20)
|
||||
final_energy.append(result.terminal_value.battery_energy_wh)
|
||||
assert final_energy[0] > final_energy[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("control", [24, 48])
|
||||
def test_continuation_prevents_emptying_at_moved_boundary(config_eos, control):
|
||||
opt, params = setup_run(config_eos, hours=96)
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 96}, "optimization": {"genetic": {"horizon_hours": control}}}
|
||||
)
|
||||
# Zero control load, with the same future local demand visible to both tails.
|
||||
params.ems.gesamtlast[60] = 1000
|
||||
params.ems.feed_in_tariff_per_wh = [0.0] * 96
|
||||
config_eos.feedintariff.direct_marketing_enabled = False
|
||||
|
||||
def choose(*a, **kw):
|
||||
from deap import creator
|
||||
|
||||
idle = creator.Individual([0] * control)
|
||||
discharge = creator.Individual([len(opt.bat_possible_charge_values)] * control)
|
||||
assert opt.toolbox.evaluate(idle)[0] <= opt.toolbox.evaluate(discharge)[0]
|
||||
return idle, {}
|
||||
|
||||
with patch.object(opt, "optimize", side_effect=choose):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.battery_energy_wh == pytest.approx(500)
|
||||
assert result.terminal_value.continuation_mode == "AUTO"
|
||||
|
||||
|
||||
def test_missing_price_inside_tail_stops_at_first_gap(config_eos):
|
||||
opt, params = setup_run(config_eos)
|
||||
params.ems.electricity_price_per_wh[30] = float("nan")
|
||||
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.effective_tail_hours == 6
|
||||
|
||||
|
||||
def test_ev_genome_and_output_are_control_only(config_eos):
|
||||
from akkudoktoreos.devices.genetic.battery import ElectricVehicleParameters
|
||||
|
||||
opt, params = setup_run(config_eos, interval=900, start_hour=10, hours=82)
|
||||
params.ev = ElectricVehicleParameters(
|
||||
device_id="ev1",
|
||||
capacity_wh=5000,
|
||||
initial_soc_percentage=0,
|
||||
min_soc_percentage=50,
|
||||
charge_rates=[0, 0.5, 1],
|
||||
)
|
||||
|
||||
def choose(*a, **kw):
|
||||
genome = opt.create_individual()
|
||||
assert len(genome) == 2 * 96
|
||||
return genome, {}
|
||||
|
||||
with patch.object(opt, "optimize", side_effect=choose):
|
||||
result = opt.optimize_ems(params)
|
||||
assert len(result.ev_charge_hours_float) == 96
|
||||
|
||||
|
||||
def test_rejected_config_update_is_atomic(config_eos):
|
||||
# The candidate is validated before the singleton is reinitialized, so a
|
||||
# rejected update must leave the running configuration untouched rather
|
||||
# than half-applied. `hours` is constrained to be non-negative.
|
||||
before = config_eos.prediction.hours
|
||||
with pytest.raises(ValueError):
|
||||
config_eos.merge_settings_from_dict({"prediction": {"hours": -1}})
|
||||
assert config_eos.prediction.hours == before
|
||||
|
||||
|
||||
def test_disabled_ac_conversion_cannot_earn_negative_price_revenue():
|
||||
bat, inv = devices()
|
||||
inv.parameters.ac_to_dc_efficiency = 0
|
||||
c = build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array([-0.001, 0.001]),
|
||||
feed_in_euro_per_wh=np.array([0.0, 0.001]),
|
||||
load_wh=np.zeros(2),
|
||||
pv_wh=np.zeros(2),
|
||||
continuation=TerminalValueCurve(),
|
||||
charge_rates=[1],
|
||||
export_rates=[1],
|
||||
direct_marketing=True,
|
||||
)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
assert bat.soc_wh == 500 # Building the tail never mutates the real battery.
|
||||
|
||||
|
||||
def test_short_native_forecast_declares_its_resolution(config_eos):
|
||||
opt, params = setup_run(config_eos, interval=900, hours=52 * 4)
|
||||
params.forecast_interval_seconds = 900
|
||||
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.effective_tail_hours == 28
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,reason",
|
||||
[
|
||||
("electricity_price_per_wh", "import price"),
|
||||
("feed_in_tariff_per_wh", "feed-in tariff"),
|
||||
],
|
||||
)
|
||||
def test_differing_provider_lengths_use_the_common_tail(config_eos, field, reason):
|
||||
opt, params = setup_run(config_eos)
|
||||
setattr(params.ems, field, getattr(params.ems, field)[:52])
|
||||
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
|
||||
result = opt.optimize_ems(params)
|
||||
assert result.terminal_value.effective_tail_hours == 28
|
||||
assert reason in result.terminal_value.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_provider_key_stays_missing():
|
||||
from types import SimpleNamespace
|
||||
|
||||
async def unavailable(*a, **kw):
|
||||
raise KeyError("price unavailable")
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
result = await bounded_forecast_array(
|
||||
SimpleNamespace(key_to_raw_series=unavailable),
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=2),
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
assert np.isnan(result).all()
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_short_prediction_horizon_shortens_the_tail(config_eos):
|
||||
# The forecast budget cannot serve the full 48 h tail. The run keeps going
|
||||
# with the 12 h that are left after the control horizon instead of failing.
|
||||
opt, params = setup_run(config_eos, hours=36, prediction_hours=36)
|
||||
assert opt.control_slots == 24
|
||||
assert opt.tail_slots == 12
|
||||
|
||||
result = opt.optimize_ems(params, ngen=2)
|
||||
assert result.terminal_value.mode == "TAIL"
|
||||
assert result.terminal_value.requested_tail_hours == 48
|
||||
assert result.terminal_value.effective_tail_hours == 12
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Native GENETIC results retain the elapsed-time grid and immutable run inputs."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"timestamp",
|
||||
["2026-03-29T03:15:00+02:00", "2026-10-25T02:30:00+02:00", "2026-10-25T02:30:00+01:00"],
|
||||
)
|
||||
async def test_native_result_retains_dst_grid_and_owned_inputs(config_eos, timestamp):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"general": {"latitude": 52.52, "longitude": 13.405},
|
||||
"prediction": {"hours": 24},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"interval_sec": 900,
|
||||
"horizon_hours": 1,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
start = to_datetime(timestamp).in_timezone("Europe/Berlin")
|
||||
get_ems(init=True).set_start_datetime(start)
|
||||
elapsed_slots = int((start - start.start_of("day")).total_seconds() // 900)
|
||||
count = elapsed_slots + 4
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
{
|
||||
"forecast_interval_seconds": 900,
|
||||
"ems": {
|
||||
"pv_forecast_wh": [0.0] * count,
|
||||
"total_load": [25.0] * count,
|
||||
"electricity_price_per_wh": [0.0003] * count,
|
||||
"feed_in_tariff_per_wh": [0.00005] * count,
|
||||
"price_per_wh_battery": 0.0,
|
||||
},
|
||||
"pv_battery": {
|
||||
"device_id": "owned_battery",
|
||||
"capacity_wh": 1000,
|
||||
"initial_soc_percentage": 50,
|
||||
},
|
||||
"inverter": {
|
||||
"device_id": "owned_inverter",
|
||||
"battery_id": "owned_battery",
|
||||
"max_power_wh": 1000,
|
||||
},
|
||||
"ev": None,
|
||||
}
|
||||
)
|
||||
optimizer = GeneticOptimization(fixed_seed=42)
|
||||
native = optimizer.optimize_ems(parameters, ngen=1, individuals=6)
|
||||
repeat = GeneticOptimization(fixed_seed=42).optimize_ems(parameters, ngen=1, individuals=6)
|
||||
assert native.start_solution == repeat.start_solution
|
||||
assert native.interval_seconds == 900
|
||||
assert native.start_solution_datetime == start
|
||||
assert native.controls_start_at_now
|
||||
assert len(native.result.load_wh_per_hour) == 4
|
||||
assert native.parameters.ems.total_load == [25.0] * 4
|
||||
parameters.ems.total_load[elapsed_slots] = 999.0
|
||||
get_ems().set_start_datetime(start.add(days=1))
|
||||
config_eos.optimization.genetic.interval_sec = 3600
|
||||
with patch(
|
||||
"akkudoktoreos.optimization.genetic.geneticsolution.get_prediction",
|
||||
side_effect=AssertionError("Native serialization must not reread providers"),
|
||||
):
|
||||
generic = await native.optimization_solution()
|
||||
plan = native.energy_management_plan()
|
||||
assert generic.valid_from == start
|
||||
assert generic.valid_until == start.add(hours=1)
|
||||
assert plan.valid_from == start
|
||||
assert plan.valid_until is None
|
||||
assert all(
|
||||
start.timestamp() <= item.execution_time.timestamp() < start.add(hours=1).timestamp()
|
||||
for item in plan.instructions
|
||||
)
|
||||
forecast = generic.prediction.to_dataframe()
|
||||
np.testing.assert_allclose(forecast["loadforecast_energy_wh"], [25.0] * 4)
|
||||
np.testing.assert_allclose(forecast["elec_price_amt_kwh"], [0.3] * 4)
|
||||
assert all(
|
||||
(right - left).total_seconds() == 900
|
||||
for left, right in zip(forecast.index, forecast.index[1:])
|
||||
)
|
||||
assert "owned_battery_soc_factor" in generic.solution.to_dataframe().columns
|
||||
|
||||
|
||||
def test_native_quarter_hour_temperatures_average_when_coarsened(config_eos):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"optimization": {"genetic": {"interval_sec": 3600, "horizon_hours": 1}}}
|
||||
)
|
||||
get_ems(init=True).set_start_datetime(
|
||||
to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
)
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
{
|
||||
"forecast_interval_seconds": 900,
|
||||
"temperature_forecast": [10, 12, 14, 16],
|
||||
"ems": {
|
||||
"pv_forecast_wh": [25.0] * 4,
|
||||
"total_load": [50.0] * 4,
|
||||
"electricity_price_per_wh": [0.0003] * 4,
|
||||
"feed_in_tariff_per_wh": 0.00005,
|
||||
"price_per_wh_battery": 0.0,
|
||||
},
|
||||
"pv_battery": None,
|
||||
"ev": None,
|
||||
"inverter": None,
|
||||
}
|
||||
)
|
||||
normalized = GeneticOptimization()._parameters_for_slot_grid(parameters)
|
||||
assert normalized.temperature_forecast == [13.0]
|
||||
assert normalized.ems.pv_forecast_wh == [100.0]
|
||||
assert normalized.ems.total_load == [200.0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_timezone", ["UTC", "Europe/Berlin"])
|
||||
@pytest.mark.parametrize("offset", ["+02:00", "+01:00"])
|
||||
@pytest.mark.parametrize("as_json_string", [False, True])
|
||||
def test_snapshot_and_warm_start_preserve_aware_instants(
|
||||
config_eos, set_other_timezone, host_timezone, offset, as_json_string
|
||||
):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from akkudoktoreos.optimization.genetic.configrequest import (
|
||||
ConfigOptimizationRequest,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
|
||||
set_other_timezone(host_timezone)
|
||||
expected = to_datetime(f"2026-10-25T02:30:00{offset}", in_timezone="Europe/Berlin")
|
||||
supplied = expected.to_iso8601_string() if as_json_string else expected
|
||||
payload = json.loads(
|
||||
(Path(__file__).parent / "testdata/genetic/optimize_result_1.json").read_text()
|
||||
)
|
||||
payload["start_solution_datetime"] = supplied
|
||||
native = GeneticSolution.model_validate(payload)
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
native.parameters.model_dump() | {"start_solution_datetime": supplied}
|
||||
)
|
||||
request = ConfigOptimizationRequest.model_validate({"start_solution_datetime": supplied})
|
||||
for model in (native, parameters, request):
|
||||
value = model.start_solution_datetime
|
||||
assert value is not None
|
||||
assert value.timestamp() == expected.timestamp()
|
||||
assert value.utcoffset() == expected.utcoffset()
|
||||
if not as_json_string:
|
||||
assert value.timezone_name == "Europe/Berlin"
|
||||
assert value.fold == expected.fold
|
||||
restored = type(model).model_validate_json(model.model_dump_json())
|
||||
assert restored.start_solution_datetime is not None
|
||||
assert restored.start_solution_datetime.timestamp() == expected.timestamp()
|
||||
assert restored.start_solution_datetime.utcoffset() == expected.utcoffset()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Real small optimizer runs covering device contracts across the public result."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.coreabc import get_ems, get_measurement
|
||||
from akkudoktoreos.core.emplan import DDBCInstruction
|
||||
from akkudoktoreos.measurement.measurement import Measurement
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, params=["UTC", "Europe/Berlin"])
|
||||
def local_clock(request: pytest.FixtureRequest, set_other_timezone: Callable[[str], str]) -> None:
|
||||
"""Run the same local-wall-clock schedules in UTC and a DST-observing zone.
|
||||
|
||||
Scenario dates intentionally have no fixed offset: each names local midnight,
|
||||
a local time window or a local departure in the selected timezone.
|
||||
"""
|
||||
set_other_timezone(request.param)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def isolated_measurement(config_eos: ConfigEOS) -> AsyncGenerator[Measurement, None]:
|
||||
"""Keep synthetic records out of the process-wide measurement singleton."""
|
||||
measurement = get_measurement()
|
||||
await measurement.delete_by_datetime(None, None)
|
||||
try:
|
||||
yield measurement
|
||||
finally:
|
||||
await measurement.delete_by_datetime(None, None)
|
||||
|
||||
|
||||
def configure(
|
||||
config: ConfigEOS,
|
||||
*,
|
||||
hours: int = 4,
|
||||
start: str = "2026-09-16T00:00:00",
|
||||
marketing: bool = False,
|
||||
) -> None:
|
||||
config.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": max(48, hours)},
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"genetic": {
|
||||
"horizon_hours": hours,
|
||||
"tail_horizon_hours": 0,
|
||||
"interval_sec": 900,
|
||||
"individuals": 12,
|
||||
"generations": 10,
|
||||
"terminal_value_mode": "FIXED",
|
||||
},
|
||||
},
|
||||
"feedintariff": {"direct_marketing_enabled": marketing},
|
||||
}
|
||||
)
|
||||
get_ems(init=True).set_start_datetime(to_datetime(start))
|
||||
|
||||
|
||||
def parameters(
|
||||
*, hours: int = 4, consumers: list[dict[str, Any]] | None = None, **kwargs: Any
|
||||
) -> GeneticOptimizationParameters:
|
||||
slots = hours * 4 + get_ems().start_datetime.hour * 4 + get_ems().start_datetime.minute // 15
|
||||
return GeneticOptimizationParameters.model_validate(
|
||||
{
|
||||
"ems": {
|
||||
"pv_prognose_wh": [0.0] * slots,
|
||||
"gesamtlast": [0.0] * slots,
|
||||
"strompreis_euro_pro_wh": [0.0003] * slots,
|
||||
"einspeiseverguetung_euro_pro_wh": [0.0001] * slots,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
},
|
||||
"inverter": {"device_id": "inv", "max_power_wh": 10000},
|
||||
"forecast_interval_seconds": 900,
|
||||
"home_appliances": consumers,
|
||||
"pv_battery": None,
|
||||
"ev": None,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def run(params: GeneticOptimizationParameters) -> tuple[GeneticOptimization, GeneticSolution]:
|
||||
optimizer = GeneticOptimization(fixed_seed=42)
|
||||
solution = optimizer.optimize_ems(params, ngen=1, individuals=12)
|
||||
return optimizer, solution
|
||||
|
||||
|
||||
def run_local_starts(solution: GeneticSolution) -> list[DateTime]:
|
||||
"""Compare scheduled instants in the run zone, independently of output zone."""
|
||||
timezone = get_ems().start_datetime.timezone_name
|
||||
assert timezone is not None
|
||||
return [moment.in_timezone(timezone) for moment in solution.appliance_starts["washer"]]
|
||||
|
||||
|
||||
def profile(**kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"device_id": "washer",
|
||||
"load_profile_power_w": [1200.0, 600.0, 300.0],
|
||||
"load_profile_interval_seconds": 600,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
|
||||
def test_real_optimizer_keeps_reverse_cycle_window_identity(config_eos: ConfigEOS) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(
|
||||
num_cycles=2,
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "02:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
_, solution = run(parameters(consumers=[consumer]))
|
||||
assert [value.hour for value in run_local_starts(solution)] == [1, 2]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(700.0)
|
||||
assert sum(solution.result.grid_consumption_wh_per_hour) == pytest.approx(700.0)
|
||||
assert solution.result.total_costs == pytest.approx(0.21)
|
||||
|
||||
|
||||
def test_real_daily_optimizer_skips_completed_cycles_only_on_first_day(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos, hours=28)
|
||||
consumer = profile(
|
||||
num_cycles=2,
|
||||
completed_cycles=1,
|
||||
schedule_mode="DAILY",
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
_, solution = run(parameters(hours=28, consumers=[consumer]))
|
||||
assert [(value.day, value.hour) for value in run_local_starts(solution)] == [
|
||||
(16, 2),
|
||||
(17, 1),
|
||||
(17, 2),
|
||||
]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(1050.0)
|
||||
|
||||
|
||||
def test_real_best_effort_multicycle_prioritizes_delay_over_cheaper_prices(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(num_cycles=2, min_cycle_gap_h=1, deadline_datetime="2026-09-15T23:00:00")
|
||||
params = parameters(consumers=[consumer])
|
||||
params.ems.electricity_price_per_wh = [0.001] * 8 + [-0.001] * 8
|
||||
_, solution = run(params)
|
||||
assert [(value.hour, value.minute) for value in run_local_starts(solution)] == [
|
||||
(0, 0),
|
||||
(1, 30),
|
||||
]
|
||||
assert solution.appliance_deadline_missed["washer"]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(700.0)
|
||||
|
||||
|
||||
def test_real_mixed_best_effort_cycle_status_survives_later_strict_cycle(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(
|
||||
num_cycles=2,
|
||||
deadline_datetime="2026-09-16T01:00:00",
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "02:00", "duration": "2 hours", "value": 0},
|
||||
{"start_time": "00:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
params = parameters(consumers=[consumer])
|
||||
params.ems.electricity_price_per_wh = [0.001] * 12 + [-0.001] * 4
|
||||
_, solution = run(params)
|
||||
assert [(value.hour, value.minute) for value in run_local_starts(solution)] == [
|
||||
(0, 0),
|
||||
(2, 0),
|
||||
]
|
||||
assert solution.appliance_deadline_missed["washer"]
|
||||
|
||||
|
||||
def test_real_warm_start_handles_changed_completed_cycle_layout(config_eos: ConfigEOS) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(
|
||||
num_cycles=2,
|
||||
time_windows={
|
||||
"windows": [
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
optimizer, first = run(parameters(consumers=[consumer]))
|
||||
consumer["completed_cycles"] = 1
|
||||
followup = parameters(
|
||||
consumers=[consumer],
|
||||
start_solution=first.start_solution,
|
||||
start_solution_datetime=first.start_solution_datetime,
|
||||
)
|
||||
second = optimizer.optimize_ems(followup, ngen=1, individuals=12)
|
||||
assert len(second.appliance_starts["washer"]) == 1
|
||||
assert run_local_starts(second)[0].hour == 2
|
||||
assert sum(second.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||||
assert first.start_solution is not None and second.start_solution is not None
|
||||
assert len(second.start_solution) == len(first.start_solution) - 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deadline", ["2026-09-15T23:00:00", "2026-09-16T00:01:00"])
|
||||
def test_real_ev_does_not_credit_energy_delivered_after_departure(
|
||||
config_eos: ConfigEOS, deadline: str
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
params = parameters(
|
||||
ev={
|
||||
"device_id": "car",
|
||||
"capacity_wh": 4000,
|
||||
"initial_soc_percentage": 0,
|
||||
"min_soc_percentage": 25,
|
||||
"charging_efficiency": 1.0,
|
||||
"max_charge_power_w": 4000,
|
||||
"charge_rates": [0.0, 1.0],
|
||||
"min_soc_deadline_datetime": deadline,
|
||||
}
|
||||
)
|
||||
optimizer, solution = run(params)
|
||||
assert optimizer._ev_soc_deadline_slot == 0
|
||||
assert (
|
||||
optimizer._ev_soc_at_deadline({"EAuto_SoC_pro_Stunde": solution.result.ev_soc_per_hour}, 0)
|
||||
== 0.0
|
||||
)
|
||||
|
||||
|
||||
def test_real_ev_target_across_midnight_uses_elapsed_slots(config_eos: ConfigEOS) -> None:
|
||||
configure(config_eos, start="2026-09-16T23:30:00")
|
||||
params = parameters(
|
||||
ev={
|
||||
"device_id": "car",
|
||||
"capacity_wh": 4000,
|
||||
"initial_soc_percentage": 0,
|
||||
"min_soc_percentage": 50,
|
||||
"charging_efficiency": 1.0,
|
||||
"max_charge_power_w": 4000,
|
||||
"charge_rates": [0.0, 1.0],
|
||||
"min_soc_deadline_datetime": "2026-09-17T00:00:00",
|
||||
}
|
||||
)
|
||||
params.ems.electricity_price_per_wh[-16:] = [0.001] * 2 + [0.00001] * 14
|
||||
optimizer, solution = run(params)
|
||||
assert optimizer._ev_soc_deadline_slot == 2
|
||||
assert solution.result.ev_soc_per_hour[2] >= 50
|
||||
assert solution.ev_charge_hours_float is not None
|
||||
assert solution.ev_charge_hours_float[:2] == [1.0, 1.0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"marketing,lcos,export_expected",
|
||||
[(False, 0.0, False), (True, 0.0, True), (True, 0.2, True), (True, 2.0, False)],
|
||||
)
|
||||
def test_real_export_respects_marketing_gate_and_storage_cost(
|
||||
config_eos: ConfigEOS, marketing: bool, lcos: float, export_expected: bool
|
||||
) -> None:
|
||||
configure(config_eos, marketing=marketing)
|
||||
params = parameters(
|
||||
pv_battery={
|
||||
"device_id": "battery",
|
||||
"capacity_wh": 1000,
|
||||
"initial_soc_percentage": 100,
|
||||
"charging_efficiency": 1.0,
|
||||
"discharging_efficiency": 1.0,
|
||||
"min_soc_percentage": 0,
|
||||
"max_charge_power_w": 4000,
|
||||
"grid_export_rates": [0.5, 1.0],
|
||||
"levelized_cost_of_storage_kwh": lcos,
|
||||
},
|
||||
inverter={"device_id": "inv", "max_power_wh": 10000, "battery_id": "battery"},
|
||||
)
|
||||
params.ems.feed_in_tariff_per_wh = [0.00001] + [0.001] * 4 + [0.00001] * 11
|
||||
_, solution = run(params)
|
||||
exported = sum(solution.result.grid_feed_in_wh_per_hour)
|
||||
if export_expected:
|
||||
assert exported == pytest.approx(1000.0)
|
||||
assert solution.result.total_revenue == pytest.approx(1.0)
|
||||
assert solution.result.total_costs == pytest.approx(lcos)
|
||||
assert any(solution.battery_grid_export_allowed)
|
||||
else:
|
||||
assert exported == pytest.approx(0.0)
|
||||
assert not any(solution.battery_grid_export_allowed)
|
||||
assert np.isfinite(solution.result.total_balance)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("custom_key", [None, "washer.completed_today"])
|
||||
async def test_real_measurement_completed_cycles_reach_request_and_optimizer(
|
||||
config_eos: ConfigEOS, custom_key: str | None, isolated_measurement: Measurement
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"devices": {
|
||||
"max_batteries": 0,
|
||||
"batteries": {},
|
||||
"max_electric_vehicles": 0,
|
||||
"electric_vehicles": {},
|
||||
"max_inverters": 1,
|
||||
"inverters": {"inv": {"max_power_w": 10000}},
|
||||
"max_home_appliances": 1,
|
||||
"home_appliances": {
|
||||
"washer": profile(
|
||||
num_cycles=2,
|
||||
cycles_completed_measurement_key=custom_key,
|
||||
cycle_time_windows={
|
||||
"windows": [
|
||||
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||||
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
key = custom_key or "washer.cycles_completed"
|
||||
assert key in config_eos.devices.measurement_keys
|
||||
measurement = isolated_measurement
|
||||
zero = get_ems().start_datetime
|
||||
await measurement.update_value(zero.subtract(days=1), key, 2.0)
|
||||
await measurement.update_value(zero, key, 1.0)
|
||||
await measurement.update_value(zero.add(seconds=1), key, 2.0)
|
||||
dates, counts = await measurement.key_to_lists(
|
||||
key=key, start_datetime=zero, end_datetime=zero.add(seconds=1)
|
||||
)
|
||||
assert len(dates) == 1 and counts == [1.0]
|
||||
prepared = await ConfigOptimizationRequest.model_validate(
|
||||
{
|
||||
"forecasts": {
|
||||
"pv_forecast_wh": [0.0] * 16,
|
||||
"total_load": [0.0] * 16,
|
||||
"electricity_price_per_wh": [0.0003] * 16,
|
||||
"feed_in_tariff_per_wh": [0.0001] * 16,
|
||||
}
|
||||
}
|
||||
).resolve()
|
||||
assert prepared.home_appliances is not None
|
||||
assert prepared.home_appliances[0].completed_cycles == 1
|
||||
_, solution = run(prepared)
|
||||
assert [start.hour for start in run_local_starts(solution)] == [2]
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_multiple_consumers_keep_separate_solution_channels(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
consumers = [
|
||||
profile(
|
||||
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "30 minutes"}]}
|
||||
),
|
||||
profile(
|
||||
device_id="dryer",
|
||||
load_profile_power_w=[2000.0],
|
||||
load_profile_interval_seconds=900,
|
||||
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "15 minutes"}]},
|
||||
),
|
||||
]
|
||||
_, solution = run(parameters(consumers=consumers))
|
||||
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||||
assert sum(solution.result.home_appliance_energy_wh["dryer"]) == pytest.approx(500.0)
|
||||
assert sum(solution.result.grid_consumption_wh_per_hour) == pytest.approx(850.0)
|
||||
exported = await solution.optimization_solution()
|
||||
table = exported.solution.to_dataframe()
|
||||
assert table["washer_energy_wh"].sum() == pytest.approx(350.0)
|
||||
assert table["dryer_energy_wh"].sum() == pytest.approx(500.0)
|
||||
assert table.index[4].hour == 1
|
||||
assert table["washer_run_op_mode"].tolist()[4:6] == [1.0, 1.0]
|
||||
assert table["dryer_run_op_mode"].tolist()[4:6] == [1.0, 0.0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_profile_zero_power_phase_keeps_device_running_until_complete(
|
||||
config_eos: ConfigEOS,
|
||||
) -> None:
|
||||
configure(config_eos)
|
||||
consumer = profile(
|
||||
load_profile_power_w=[1200.0, 0.0, 1200.0],
|
||||
load_profile_interval_seconds=900,
|
||||
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "45 minutes"}]},
|
||||
)
|
||||
_, solution = run(parameters(consumers=[consumer]))
|
||||
exported = await solution.optimization_solution()
|
||||
table = exported.solution.to_dataframe()
|
||||
assert table["washer_energy_wh"].tolist()[4:7] == [300.0, 0.0, 300.0]
|
||||
assert table["washer_run_op_mode"].tolist()[4:7] == [1.0, 1.0, 1.0]
|
||||
plan = solution.energy_management_plan()
|
||||
assert {item.resource_id for item in plan.instructions} == {"washer"}
|
||||
commands = [
|
||||
(item.execution_time.hour, item.execution_time.minute, str(item.operation_mode_id))
|
||||
for item in plan.instructions
|
||||
if isinstance(item, DDBCInstruction)
|
||||
]
|
||||
assert commands == [(0, 0, "OFF"), (1, 0, "RUN"), (1, 45, "OFF")]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Raw forecast coverage must survive resampling without inventing valid intervals."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("drop,missing", [(1, 4), (2, 8)])
|
||||
async def test_hourly_gaps_and_unavailable_tail_are_not_forward_filled(drop, missing):
|
||||
index = pd.date_range("2026-09-16T00:00:00Z", periods=4, freq="h").delete(drop)
|
||||
prediction = Mock(key_to_raw_series=AsyncMock(return_value=pd.Series([100.0] * 3, index=index)))
|
||||
start = to_datetime("2026-09-16T00:00:00Z", in_timezone="UTC")
|
||||
values = await bounded_forecast_array(
|
||||
prediction,
|
||||
key="pv",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=5),
|
||||
interval=to_duration(900),
|
||||
)
|
||||
assert np.isnan(values[missing : missing + 4]).all()
|
||||
assert np.isnan(values[16:]).all()
|
||||
assert np.isfinite(values[:4]).all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downsampling_requires_complete_coverage_and_uses_interval_average():
|
||||
index = pd.date_range("2026-09-16T00:00:00Z", periods=8, freq="15min")
|
||||
series = pd.Series([100.0, 200.0, 300.0, 400.0, 100.0, np.nan, 100.0, 100.0], index=index)
|
||||
prediction = Mock(key_to_raw_series=AsyncMock(return_value=series))
|
||||
start = to_datetime("2026-09-16T00:00:00Z", in_timezone="UTC")
|
||||
values = await bounded_forecast_array(
|
||||
prediction,
|
||||
key="pv",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=2),
|
||||
interval=to_duration(3600),
|
||||
)
|
||||
assert values[0] == 250.0
|
||||
assert np.isnan(values[1])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_aligned_source_intervals_are_weighted_by_actual_overlap():
|
||||
index = pd.date_range("2026-09-16T00:05:00Z", periods=4, freq="15min")
|
||||
prediction = Mock(
|
||||
key_to_raw_series=AsyncMock(
|
||||
return_value=pd.Series([100.0, 400.0, 700.0, 1000.0], index=index)
|
||||
)
|
||||
)
|
||||
start = to_datetime("2026-09-16T00:00:00Z", in_timezone="UTC")
|
||||
values = await bounded_forecast_array(
|
||||
prediction,
|
||||
key="pv",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=1),
|
||||
interval=to_duration(900),
|
||||
)
|
||||
assert np.isnan(values[0])
|
||||
assert values[1] == pytest.approx(300.0)
|
||||
assert values[2] == pytest.approx(600.0)
|
||||
@@ -0,0 +1,476 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from deap import creator, tools
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
def _configure_hourly_grid(config_eos: ConfigEOS, *, start_hour: int = 0) -> None:
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval_sec": 3600}
|
||||
},
|
||||
}
|
||||
)
|
||||
get_ems(init=True).set_start_datetime(to_datetime().set(hour=start_hour, minute=0))
|
||||
|
||||
|
||||
def test_ev_repair_is_resimulated_before_fitness_assignment(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = True
|
||||
opt.ev_possible_charge_values = [0.0, 1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
individual = creator.Individual([0] * opt.control_slots + [1] * opt.control_slots)
|
||||
|
||||
first_result = {
|
||||
"Gesamtbilanz_Euro": 10.0,
|
||||
"Gesamt_Verluste": 0.0,
|
||||
"EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0),
|
||||
}
|
||||
repaired_result = {
|
||||
"Gesamtbilanz_Euro": 1.0,
|
||||
"Gesamt_Verluste": 0.0,
|
||||
"EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0),
|
||||
}
|
||||
parameters = SimpleNamespace(
|
||||
ems=SimpleNamespace(price_per_wh_battery=0.0),
|
||||
ev=None,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
opt, "evaluate_inner", side_effect=[first_result, repaired_result]
|
||||
) as evaluate:
|
||||
fitness = opt.evaluate(individual, parameters, start_hour=0, worst_case=False) # type: ignore[arg-type]
|
||||
|
||||
assert evaluate.call_count == 2
|
||||
assert fitness == pytest.approx((1.0,))
|
||||
assert individual[opt.control_slots :] == [0] * opt.control_slots
|
||||
|
||||
|
||||
def test_fitness_cache_restores_canonical_ev_genome(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = True
|
||||
opt.ev_possible_charge_values = [0.0, 1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
parameters = SimpleNamespace(
|
||||
ems=SimpleNamespace(price_per_wh_battery=0.0),
|
||||
ev=None,
|
||||
)
|
||||
result = {
|
||||
"Gesamtbilanz_Euro": 1.0,
|
||||
"Gesamt_Verluste": 0.0,
|
||||
"EAuto_SoC_pro_Stunde": np.full(opt.control_slots, 100.0),
|
||||
}
|
||||
first = creator.Individual([0] * opt.control_slots + [1] * opt.control_slots)
|
||||
duplicate = creator.Individual(first)
|
||||
opt._fitness_cache_enabled = True
|
||||
|
||||
with patch.object(opt, "evaluate_inner", return_value=result) as evaluate:
|
||||
first_fitness = opt.evaluate(first, parameters, 0, False) # type: ignore[arg-type]
|
||||
duplicate_fitness = opt.evaluate(duplicate, parameters, 0, False) # type: ignore[arg-type]
|
||||
|
||||
# The miss evaluates and then re-evaluates the repaired EV plan. The duplicate
|
||||
# is served directly from the original-key alias and receives the canonical genome.
|
||||
assert evaluate.call_count == 2
|
||||
assert first_fitness == duplicate_fitness
|
||||
assert duplicate == first
|
||||
assert duplicate[opt.control_slots :] == [0] * opt.control_slots
|
||||
assert duplicate.extra_data == first.extra_data
|
||||
assert opt._fitness_cache_hits == 1
|
||||
assert opt._fitness_cache_misses == 1
|
||||
|
||||
|
||||
def test_fitness_cache_never_stores_failed_evaluations(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
parameters = SimpleNamespace(
|
||||
ems=SimpleNamespace(price_per_wh_battery=0.0),
|
||||
ev=None,
|
||||
)
|
||||
first = creator.Individual([0] * opt.control_slots)
|
||||
duplicate = creator.Individual(first)
|
||||
opt._fitness_cache_enabled = True
|
||||
|
||||
with patch.object(opt, "evaluate_inner", side_effect=RuntimeError("transient")) as evaluate:
|
||||
assert opt.evaluate(first, parameters, 0, False) == (100000.0,) # type: ignore[arg-type]
|
||||
assert opt.evaluate(duplicate, parameters, 0, False) == (100000.0,) # type: ignore[arg-type]
|
||||
|
||||
assert evaluate.call_count == 2
|
||||
assert opt._fitness_cache_hits == 0
|
||||
assert opt._fitness_cache_misses == 2
|
||||
assert opt._fitness_cache == {}
|
||||
|
||||
|
||||
def test_fitness_cache_includes_first_run_relative_control(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos, start_hour=10)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=10)
|
||||
parameters = SimpleNamespace(
|
||||
ems=SimpleNamespace(price_per_wh_battery=0.0),
|
||||
ev=None,
|
||||
)
|
||||
result = {
|
||||
"Gesamtbilanz_Euro": 1.0,
|
||||
"Gesamt_Verluste": 0.0,
|
||||
"EAuto_SoC_pro_Stunde": np.zeros(opt.control_slots),
|
||||
}
|
||||
first = creator.Individual([0] * opt.control_slots)
|
||||
elapsed_variant = creator.Individual(first)
|
||||
elapsed_variant[0] = 1
|
||||
opt._fitness_cache_enabled = True
|
||||
|
||||
with patch.object(opt, "evaluate_inner", return_value=result) as evaluate:
|
||||
first_fitness = opt.evaluate(first, parameters, 10, False) # type: ignore[arg-type]
|
||||
variant_fitness = opt.evaluate(elapsed_variant, parameters, 10, False) # type: ignore[arg-type]
|
||||
|
||||
assert evaluate.call_count == 2
|
||||
assert first_fitness == variant_fitness
|
||||
assert opt._fitness_cache_hits == 0
|
||||
|
||||
|
||||
def test_mutated_warm_start_neighbors_stay_within_control_horizon(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos, start_hour=10)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=10)
|
||||
start_solution = [0.0] * opt.control_slots
|
||||
|
||||
neighbors = opt._mutated_warm_start_neighbors(start_solution, count=5)
|
||||
|
||||
assert len(neighbors) == 5
|
||||
assert len({tuple(neighbor) for neighbor in neighbors}) == 5
|
||||
assert all(len(neighbor) == opt.control_slots for neighbor in neighbors)
|
||||
assert all(neighbor != start_solution for neighbor in neighbors)
|
||||
|
||||
|
||||
def test_initial_population_uses_fixed_seed_budget_and_configured_population(
|
||||
config_eos: ConfigEOS,
|
||||
):
|
||||
_configure_hourly_grid(config_eos)
|
||||
config_eos.optimization.genetic.individuals = 300
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
start_solution = [5.0] * opt.control_slots
|
||||
warm_neighbors = [[6] * opt.control_slots for _ in range(50)]
|
||||
educated = [[7] * opt.control_slots for _ in range(100)]
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_evolution(population, **kwargs):
|
||||
captured["population"] = list(population)
|
||||
captured["mu"] = kwargs["mu"]
|
||||
captured["lambda"] = kwargs["lambda_"]
|
||||
for individual in population:
|
||||
individual.fitness.values = (float(sum(individual)),)
|
||||
individual.extra_data = (0.0, 0.0, 0.0)
|
||||
kwargs["halloffame"].update(population)
|
||||
return population, SimpleNamespace(select=lambda _name: [])
|
||||
|
||||
with (
|
||||
patch.object(opt, "_mutated_warm_start_neighbors", return_value=warm_neighbors),
|
||||
patch.object(opt, "_educated_guess_individuals", return_value=educated),
|
||||
patch.object(
|
||||
opt.toolbox,
|
||||
"population",
|
||||
side_effect=lambda n: [creator.Individual([9] * opt.control_slots) for _ in range(n)],
|
||||
),
|
||||
patch.object(opt, "_evolve_population_adaptive", side_effect=fake_evolution),
|
||||
):
|
||||
opt.optimize(start_solution=start_solution, ngen=1)
|
||||
|
||||
population = captured["population"]
|
||||
assert isinstance(population, list)
|
||||
first_genes = [individual[0] for individual in population]
|
||||
assert len(population) == 300
|
||||
assert first_genes.count(5) == 10
|
||||
assert first_genes.count(6) == 50
|
||||
assert first_genes.count(7) == 100
|
||||
assert first_genes.count(9) == 140
|
||||
assert captured["mu"] == 300
|
||||
assert captured["lambda"] == 300
|
||||
|
||||
|
||||
def test_small_population_scales_warm_and_educated_seed_families(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
config_eos.optimization.genetic.individuals = 100
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
start_solution = [5.0] * opt.control_slots
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def warm_neighbors(_solution, count):
|
||||
captured["warm_count"] = count
|
||||
return [[6] * opt.control_slots for _ in range(count)]
|
||||
|
||||
def educated(count):
|
||||
captured["educated_count"] = count
|
||||
return [[7] * opt.control_slots for _ in range(count)]
|
||||
|
||||
def fake_evolution(population, **kwargs):
|
||||
captured["population"] = list(population)
|
||||
captured["mu"] = kwargs["mu"]
|
||||
captured["lambda"] = kwargs["lambda_"]
|
||||
for individual in population:
|
||||
individual.fitness.values = (float(sum(individual)),)
|
||||
individual.extra_data = (0.0, 0.0, 0.0)
|
||||
kwargs["halloffame"].update(population)
|
||||
return population, SimpleNamespace(select=lambda _name: [])
|
||||
|
||||
with (
|
||||
patch.object(opt, "_mutated_warm_start_neighbors", side_effect=warm_neighbors),
|
||||
patch.object(opt, "_educated_guess_individuals", side_effect=educated),
|
||||
patch.object(
|
||||
opt.toolbox,
|
||||
"population",
|
||||
side_effect=lambda n: [creator.Individual([9] * opt.control_slots) for _ in range(n)],
|
||||
),
|
||||
patch.object(opt, "_evolve_population_adaptive", side_effect=fake_evolution),
|
||||
):
|
||||
opt.optimize(start_solution=start_solution, ngen=1)
|
||||
|
||||
population = captured["population"]
|
||||
assert isinstance(population, list)
|
||||
first_genes = [individual[0] for individual in population]
|
||||
assert len(population) == 100
|
||||
assert first_genes.count(5) == 10
|
||||
assert first_genes.count(6) == 20
|
||||
assert first_genes.count(7) == 40
|
||||
assert first_genes.count(9) == 30
|
||||
assert captured["warm_count"] == 20
|
||||
assert captured["educated_count"] == 40
|
||||
assert captured["mu"] == 100
|
||||
assert captured["lambda"] == 100
|
||||
|
||||
|
||||
def test_adaptive_evolution_soft_restarts_collapsed_population(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
opt.toolbox.register("evaluate", lambda individual: (float(sum(individual)),))
|
||||
population = [creator.Individual([0] * opt.control_slots) for _ in range(20)]
|
||||
stats = tools.Statistics(lambda individual: individual.fitness.values)
|
||||
stats.register("min", np.min)
|
||||
stats.register("avg", np.mean)
|
||||
stats.register("max", np.max)
|
||||
halloffame = tools.HallOfFame(1)
|
||||
|
||||
fresh = [creator.Individual([value] + [0] * (opt.control_slots - 1)) for value in range(1, 20)]
|
||||
with patch.object(opt, "_fresh_population", return_value=fresh) as create_fresh:
|
||||
evolved, log = opt._evolve_population_adaptive(
|
||||
population,
|
||||
mu=20,
|
||||
lambda_=20,
|
||||
ngen=1,
|
||||
stats=stats,
|
||||
halloffame=halloffame,
|
||||
)
|
||||
|
||||
create_fresh.assert_called_once_with(19, educated_fraction=0.40)
|
||||
assert log.select("restart") == [0, 1]
|
||||
assert log.select("immigrants") == [0, 19]
|
||||
assert opt._adaptive_evolution_metrics["soft_restarts"] == 1
|
||||
assert opt._population_diversity(evolved) == pytest.approx(1.0)
|
||||
assert halloffame[0].fitness.values == (0.0,)
|
||||
|
||||
|
||||
def test_local_search_moves_weak_export_to_later_expensive_import(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.optimize_dc_charge = True
|
||||
opt.optimize_battery_grid_export = True
|
||||
opt.bat_possible_charge_values = [1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
slots = opt.control_slots
|
||||
export_state = 5
|
||||
self_consumption_state = 6
|
||||
discharge_state = 1
|
||||
source = 10
|
||||
targets = list(range(20, 32))
|
||||
base = [self_consumption_state] * slots
|
||||
base[source] = export_state
|
||||
for slot in targets:
|
||||
base[slot] = 0
|
||||
|
||||
opt.simulation.elect_price_hourly = np.full(slots, 0.10)
|
||||
opt.simulation.elect_price_hourly[targets] = 0.30
|
||||
opt.simulation.elect_revenue_per_hour_arr = np.full(slots, 0.05)
|
||||
opt.simulation.elect_revenue_per_hour_arr[source] = 0.20
|
||||
opt.simulation.pv_prediction_wh = np.zeros(slots)
|
||||
opt.simulation.load_energy_array = np.full(slots, 100.0)
|
||||
|
||||
def evaluate(individual):
|
||||
export_value = -0.20 if individual[source] == export_state else 0.0
|
||||
avoided_import = -0.05 * sum(individual[slot] == discharge_state for slot in targets)
|
||||
return (export_value + avoided_import,)
|
||||
|
||||
opt.toolbox.register("evaluate", evaluate)
|
||||
incumbent = creator.Individual(base)
|
||||
incumbent.fitness.values = evaluate(incumbent)
|
||||
|
||||
best, evaluations, improvements, initial, final = opt._locally_improve_grid_export(
|
||||
incumbent,
|
||||
max_evaluations=96,
|
||||
)
|
||||
|
||||
assert evaluations > 0
|
||||
assert improvements == 1
|
||||
assert final < initial
|
||||
assert best[source] == self_consumption_state
|
||||
assert sum(best[slot] == discharge_state for slot in targets) >= 6
|
||||
|
||||
|
||||
def test_educated_guesses_encode_high_price_direct_marketing(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.optimize_dc_charge = True
|
||||
opt.optimize_battery_grid_export = True
|
||||
opt.bat_possible_charge_values = [1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
slots = opt.control_slots
|
||||
opt.simulation.elect_price_hourly = np.linspace(0.0001, 0.0004, slots)
|
||||
opt.simulation.elect_revenue_per_hour_arr = np.linspace(0.00001, 0.0003, slots)
|
||||
opt.simulation.pv_prediction_wh = np.full(slots, 1000.0)
|
||||
opt.simulation.load_energy_array = np.full(slots, 500.0)
|
||||
|
||||
guesses = opt._educated_guess_individuals()
|
||||
|
||||
dc_allowed_state = 4
|
||||
export_state = 5
|
||||
self_consumption_state = 6
|
||||
assert len(guesses) == opt.EDUCATED_GUESS_TARGET
|
||||
assert all(len(guess) == slots for guess in guesses)
|
||||
assert any(dc_allowed_state in guess or self_consumption_state in guess for guess in guesses)
|
||||
assert any(self_consumption_state in guess for guess in guesses)
|
||||
assert any(guess[-1] == export_state for guess in guesses)
|
||||
|
||||
|
||||
def test_flat_feed_in_tariff_does_not_seed_direct_marketing(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.optimize_dc_charge = True
|
||||
opt.optimize_battery_grid_export = True
|
||||
opt.bat_possible_charge_values = [1.0]
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
slots = opt.control_slots
|
||||
opt.simulation.elect_price_hourly = np.linspace(0.0001, 0.0004, slots)
|
||||
opt.simulation.elect_revenue_per_hour_arr = np.full(slots, 0.00005)
|
||||
opt.simulation.pv_prediction_wh = np.full(slots, 1000.0)
|
||||
opt.simulation.load_energy_array = np.full(slots, 500.0)
|
||||
|
||||
guesses = opt._educated_guess_individuals()
|
||||
|
||||
export_state = 5
|
||||
assert all(export_state not in guess for guess in guesses)
|
||||
|
||||
|
||||
def _rated(genome: list[int], fitness: float, *, protection: int = 0):
|
||||
"""Build an evaluated individual, optionally a protected immigrant."""
|
||||
individual = creator.Individual(genome)
|
||||
individual.fitness.values = (fitness,)
|
||||
if protection:
|
||||
individual.immigrant_protection = protection
|
||||
return individual
|
||||
|
||||
|
||||
def test_diversity_boost_threshold_stays_below_selection_floor():
|
||||
# The selection guarantees SELECTION_DIVERSITY_FLOOR unique genomes, so a
|
||||
# boost threshold at or above the floor would fire in every converged
|
||||
# generation and turn the boost into the normal operating state.
|
||||
assert (
|
||||
GeneticOptimization.DIVERSITY_BOOST_THRESHOLD
|
||||
< GeneticOptimization.SELECTION_DIVERSITY_FLOOR
|
||||
)
|
||||
|
||||
|
||||
def _immigrant_selection_pool(opt: GeneticOptimization, protection: int):
|
||||
"""Converged incumbents plus fresh immigrants that the tournament dislikes.
|
||||
|
||||
The incumbents already carry more unique genomes than
|
||||
``SELECTION_DIVERSITY_FLOOR`` demands, so the duplicate repair has no reason
|
||||
to reach for an immigrant and only the protection can seat one.
|
||||
"""
|
||||
slots = opt.control_slots
|
||||
incumbents = [
|
||||
_rated([1, index] + [0] * (slots - 2), -5.73 + index * 1e-4) for index in range(100)
|
||||
]
|
||||
offspring = [
|
||||
_rated([2, index] + [0] * (slots - 2), -5.72 + index * 1e-4) for index in range(88)
|
||||
]
|
||||
offspring.extend(
|
||||
_rated([3, index, index] + [0] * (slots - 3), 3.0 + index, protection=protection)
|
||||
for index in range(12)
|
||||
)
|
||||
return incumbents, offspring
|
||||
|
||||
|
||||
def test_protected_immigrants_survive_the_selection(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
seated = {}
|
||||
for protection in (0, opt.IMMIGRANT_PROTECTION_GENERATIONS):
|
||||
incumbents, offspring = _immigrant_selection_pool(opt, protection)
|
||||
selected = opt._select_diverse(incumbents + offspring, 100)
|
||||
seated[protection] = sum(1 for candidate in selected if candidate[0] == 3)
|
||||
# The incumbent is never evicted to make room for an immigrant.
|
||||
assert min(candidate.fitness.values[0] for candidate in selected) == pytest.approx(-5.73)
|
||||
|
||||
# Without protection the tournament removes every immigrant in the
|
||||
# generation it is born, so its genes never get to recombine.
|
||||
assert seated[0] == 0
|
||||
assert seated[opt.IMMIGRANT_PROTECTION_GENERATIONS] == 12
|
||||
|
||||
|
||||
def test_immigrant_protection_expires_after_its_generations(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
|
||||
immigrants = [_rated([3, 0, 0], 3.0, protection=opt.IMMIGRANT_PROTECTION_GENERATIONS)]
|
||||
for _ in range(opt.IMMIGRANT_PROTECTION_GENERATIONS):
|
||||
assert immigrants[0].immigrant_protection > 0
|
||||
opt._age_immigrant_protection(immigrants)
|
||||
assert immigrants[0].immigrant_protection == 0
|
||||
|
||||
# Aging is idempotent once the protection is spent.
|
||||
opt._age_immigrant_protection(immigrants)
|
||||
assert immigrants[0].immigrant_protection == 0
|
||||
|
||||
|
||||
def test_offspring_do_not_inherit_immigrant_protection(config_eos: ConfigEOS):
|
||||
_configure_hourly_grid(config_eos)
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = False
|
||||
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
|
||||
opt.toolbox.register("evaluate", lambda individual: (float(sum(individual)),))
|
||||
|
||||
parents = [
|
||||
_rated([0] * opt.control_slots, 0.0, protection=opt.IMMIGRANT_PROTECTION_GENERATIONS)
|
||||
for _ in range(4)
|
||||
]
|
||||
offspring = opt._make_offspring(parents, 8, mutation_probability=1.0)
|
||||
assert all(getattr(child, "immigrant_protection", 0) == 0 for child in offspring)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""A warm start from an earlier run is aligned with the slot this run starts in.
|
||||
|
||||
Genomes are run-relative: gene 0 controls the slot the run starts in. Reusing
|
||||
the previous solution unchanged after a slot boundary describes every decision
|
||||
one slot too late, and a search that keeps the seed postpones a planned action
|
||||
by one slot per run.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime
|
||||
|
||||
|
||||
def _optimizer(
|
||||
config_eos: ConfigEOS,
|
||||
*,
|
||||
interval: int,
|
||||
optimize_ev: bool = False,
|
||||
n_appliance_genes: int = 0,
|
||||
) -> tuple[GeneticOptimization, DateTime]:
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 24, "interval_sec": interval}
|
||||
},
|
||||
}
|
||||
)
|
||||
slot0 = get_ems(init=True).set_start_datetime(to_datetime().set(hour=8, minute=0, second=0))
|
||||
opt = GeneticOptimization(fixed_seed=42)
|
||||
opt.optimize_ev = optimize_ev
|
||||
opt.appliance_layout = SimpleNamespace(n_genes=n_appliance_genes, genes=[]) # type: ignore[assignment]
|
||||
opt._slot0_datetime = slot0
|
||||
return opt, slot0
|
||||
|
||||
|
||||
def _genes(start: int, stop: int) -> list[float]:
|
||||
return [float(value) for value in range(start, stop)]
|
||||
|
||||
|
||||
def test_quarter_hour_warm_start_moves_one_slot_forward(config_eos: ConfigEOS):
|
||||
# 07:45 run: export genes (32) at 08:00 and 08:15. Unshifted, the 08:00 run
|
||||
# would read them as 08:15 and 08:30.
|
||||
opt, slot0 = _optimizer(config_eos, interval=900)
|
||||
previous = [19.0, 32.0, 32.0, 14.0] + [36.0] * (opt.control_slots - 4)
|
||||
|
||||
aligned = opt._start_solution_for_run_start(previous, slot0.subtract(minutes=15))
|
||||
|
||||
assert aligned == [32, 32, 14] + [36] * (opt.control_slots - 3)
|
||||
|
||||
|
||||
def test_elapsed_slots_are_dropped_per_block_and_appliance_genes_kept(config_eos: ConfigEOS):
|
||||
opt, slot0 = _optimizer(config_eos, interval=3600, optimize_ev=True, n_appliance_genes=1)
|
||||
slots = opt.control_slots
|
||||
battery = _genes(0, slots)
|
||||
ev = _genes(100, 100 + slots)
|
||||
appliance = [3.0]
|
||||
|
||||
aligned = opt._start_solution_for_run_start(battery + ev + appliance, slot0.subtract(hours=3))
|
||||
|
||||
assert aligned == (
|
||||
_genes(3, slots)
|
||||
+ [slots - 1.0] * 3
|
||||
+ _genes(103, 100 + slots)
|
||||
+ [100 + slots - 1.0] * 3
|
||||
+ appliance
|
||||
)
|
||||
|
||||
|
||||
def test_same_slot_or_unknown_start_keeps_warm_start(config_eos: ConfigEOS):
|
||||
opt, slot0 = _optimizer(config_eos, interval=900)
|
||||
previous = _genes(0, opt.control_slots)
|
||||
|
||||
assert opt._start_solution_for_run_start(previous, slot0) == previous
|
||||
assert opt._start_solution_for_run_start(previous, None) == previous
|
||||
assert opt._start_solution_for_run_start(None, slot0) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offset_minutes",
|
||||
[
|
||||
pytest.param(-24 * 60, id="all-control-slots-elapsed"),
|
||||
pytest.param(15, id="starts-after-this-run"),
|
||||
],
|
||||
)
|
||||
def test_unusable_warm_start_is_dropped(config_eos: ConfigEOS, offset_minutes: int):
|
||||
opt, slot0 = _optimizer(config_eos, interval=900)
|
||||
previous = _genes(0, opt.control_slots)
|
||||
|
||||
assert opt._start_solution_for_run_start(previous, slot0.add(minutes=offset_minutes)) is None
|
||||
|
||||
|
||||
def test_start_datetime_falls_back_to_last_solution_of_this_server(
|
||||
config_eos: ConfigEOS, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
opt, slot0 = _optimizer(config_eos, interval=900)
|
||||
last_start = slot0.subtract(minutes=15)
|
||||
monkeypatch.setattr(
|
||||
type(get_ems()),
|
||||
"_genetic_solution",
|
||||
SimpleNamespace(start_solution=[19.0, 32.0, 14.0], start_solution_datetime=last_start),
|
||||
)
|
||||
|
||||
same = SimpleNamespace(start_solution=[19, 32, 14], start_solution_datetime=None)
|
||||
other = SimpleNamespace(start_solution=[19, 32, 15], start_solution_datetime=None)
|
||||
explicit_start = slot0.subtract(minutes=30)
|
||||
explicit = SimpleNamespace(start_solution=[19, 32, 14], start_solution_datetime=explicit_start)
|
||||
|
||||
assert opt._resolve_start_solution_datetime(same) == last_start # type: ignore[arg-type]
|
||||
assert opt._resolve_start_solution_datetime(other) is None # type: ignore[arg-type]
|
||||
assert opt._resolve_start_solution_datetime(explicit) == explicit_start # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_parameters_accept_iso_start_solution_datetime(config_eos: ConfigEOS):
|
||||
parameters = GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems=GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[0.0, 0.0],
|
||||
strompreis_euro_pro_wh=[0.0, 0.0],
|
||||
einspeiseverguetung_euro_pro_wh=0.0,
|
||||
preis_euro_pro_wh_akku=0.0,
|
||||
gesamtlast=[0.0, 0.0],
|
||||
)
|
||||
),
|
||||
pv_akku=None,
|
||||
inverter=None,
|
||||
eauto=None,
|
||||
start_solution=[1.0, 2.0],
|
||||
start_solution_datetime="2026-09-14T07:45:00+02:00",
|
||||
)
|
||||
)
|
||||
|
||||
assert parameters.start_solution_datetime is not None
|
||||
assert compare_datetimes(
|
||||
parameters.start_solution_datetime, to_datetime("2026-09-14T07:45:00+02:00")
|
||||
).equal
|
||||
@@ -2,11 +2,9 @@ import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from pypdf import PdfReader
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
@@ -22,7 +20,7 @@ from akkudoktoreos.optimization.genetic.geneticvisualize import (
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
ems_eos = get_ems(init=True) # init once
|
||||
ems_eos = get_ems(init=True) # init once
|
||||
|
||||
DIR_TESTDATA = Path(__file__).parent / "testdata" / "genetic"
|
||||
|
||||
@@ -40,6 +38,7 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]):
|
||||
else:
|
||||
assert actual[key] == pytest.approx(value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"fn_in, fn_out, ngen, break_even",
|
||||
@@ -67,30 +66,30 @@ async def test_optimize(
|
||||
# Assure configuration holds the correct values
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {
|
||||
"hours": 48
|
||||
},
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"genetic": {
|
||||
"horizon_hours": 48,
|
||||
"horizon_hours": 38,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
"individuals": 300,
|
||||
"generations": 10,
|
||||
"penalties": {
|
||||
"ev_soc_miss": 10,
|
||||
"ac_charge_break_even": break_even,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
"devices": {
|
||||
"max_electric_vehicles": 1,
|
||||
"electric_vehicles": { "ev1":
|
||||
{
|
||||
"electric_vehicles": {
|
||||
"ev1": {
|
||||
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Load input and output data
|
||||
@@ -99,7 +98,9 @@ async def test_optimize(
|
||||
input_data = GeneticOptimizationParameters(**json.load(f_in))
|
||||
|
||||
# Fake energy management run start datetime
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=fixed_start_hour))
|
||||
ems_eos.set_start_datetime(
|
||||
to_datetime("2026-09-16T10:00:00+02:00", in_timezone="Europe/Berlin")
|
||||
)
|
||||
|
||||
# Throw away any cached results of the last energy management run.
|
||||
CacheEnergyManagementStore().clear()
|
||||
@@ -115,31 +116,9 @@ async def test_optimize(
|
||||
parameters=input_data, start_hour=fixed_start_hour, ngen=ngen
|
||||
)
|
||||
|
||||
# Write test output to file, so we can take it as new data on intended change
|
||||
TESTDATA_FILE = DIR_TESTDATA / f"new_{fn_out}"
|
||||
with TESTDATA_FILE.open("w", encoding="utf-8", newline="\n") as f_out:
|
||||
f_out.write(genetic_solution.model_dump_json(indent=4, exclude_unset=True))
|
||||
|
||||
solution_file = DIR_TESTDATA / fn_out
|
||||
# In case a new test case is added, we don't want to fail here, so the new output is written
|
||||
# to disk before
|
||||
try:
|
||||
with solution_file.open("r") as f_out:
|
||||
expected_data = json.load(f_out)
|
||||
expected_result = GeneticSolution(**expected_data)
|
||||
except ValidationError:
|
||||
# Expected genetic solution data does not fit to GeneticSolution data schema
|
||||
# Possibly the GeneticSolution class changed.
|
||||
pytest.fail(
|
||||
f"ValidationError: Can not load expected solution from {solution_file}\n"
|
||||
f"cp {TESTDATA_FILE} {solution_file}\n"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# Should not happen
|
||||
pytest.fail(
|
||||
f"FileNotFoundError: Can not load expected solution from {solution_file}\n"
|
||||
f"cp {TESTDATA_FILE} {solution_file}\n"
|
||||
)
|
||||
# Historical payloads still deserialize with deprecated English/German aliases.
|
||||
with (DIR_TESTDATA / fn_out).open("r") as expected_file:
|
||||
expected_result = GeneticSolution.model_validate(json.load(expected_file))
|
||||
|
||||
# Keep the output contract, but do not demand an identical stochastic
|
||||
# schedule or monetary golden from the previous direct-consumption model.
|
||||
@@ -148,10 +127,8 @@ async def test_optimize(
|
||||
expected_slots = len(input_data.ems.pv_forecast_wh) - fixed_start_hour
|
||||
assert len(result.grid_consumption_wh_per_hour) == expected_slots
|
||||
assert len(result.grid_feed_in_wh_per_hour) == expected_slots
|
||||
prices = np.asarray(genetic_solution.parameters.ems.electricity_price_per_wh)[fixed_start_hour:]
|
||||
tariffs = np.asarray(genetic_solution.parameters.ems.feed_in_tariff_per_wh)
|
||||
if tariffs.ndim > 0:
|
||||
tariffs = tariffs[fixed_start_hour:]
|
||||
prices = np.asarray(genetic_solution.parameters.ems.electricity_price_per_wh)
|
||||
tariffs = np.asarray(genetic_solution.parameters.ems.feed_in_tariff_per_wh)[:expected_slots]
|
||||
expected_costs = np.asarray(result.grid_consumption_wh_per_hour) * prices
|
||||
expected_revenues = np.asarray(result.grid_feed_in_wh_per_hour) * tariffs
|
||||
np.testing.assert_allclose(result.costs_per_hour, expected_costs)
|
||||
@@ -167,11 +144,25 @@ async def test_optimize(
|
||||
|
||||
# Check the correct generic optimization solution is created
|
||||
optimization_solution = await genetic_solution.optimization_solution()
|
||||
# @TODO
|
||||
dataframe = optimization_solution.solution.to_dataframe()
|
||||
assert len(dataframe) == expected_slots
|
||||
assert optimization_solution.valid_from == genetic_solution.start_solution_datetime
|
||||
assert optimization_solution.valid_until == ems_eos.start_datetime.add(hours=expected_slots)
|
||||
assert genetic_solution.controls_start_at_now
|
||||
assert len(genetic_solution.ac_charge) == expected_slots
|
||||
assert len(genetic_solution.dc_charge) == expected_slots
|
||||
assert len(genetic_solution.discharge_allowed) == expected_slots
|
||||
|
||||
# Check the correct generic energy management plan is created
|
||||
plan = genetic_solution.energy_management_plan()
|
||||
# @TODO
|
||||
assert plan.valid_from == optimization_solution.valid_from
|
||||
assert plan.valid_until is None
|
||||
assert optimization_solution.valid_from is not None
|
||||
assert optimization_solution.valid_until is not None
|
||||
assert all(
|
||||
optimization_solution.valid_from <= item.execution_time < optimization_solution.valid_until
|
||||
for item in plan.instructions
|
||||
)
|
||||
|
||||
# Check visualization works
|
||||
pdf = genetic_prepare_visualize(
|
||||
@@ -180,8 +171,4 @@ async def test_optimize(
|
||||
assert pdf.startswith(b"%PDF-")
|
||||
|
||||
reader = PdfReader(BytesIO(pdf))
|
||||
assert len(reader.pages) == 6
|
||||
|
||||
|
||||
# Everything passed, remove generated files
|
||||
TESTDATA_FILE.unlink()
|
||||
assert len(reader.pages) >= 6
|
||||
|
||||
@@ -12,6 +12,7 @@ import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
@@ -35,7 +36,8 @@ def prepare_tariffs(config_eos):
|
||||
"devices": {
|
||||
"max_batteries": 0,
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 0,
|
||||
"max_inverters": 1,
|
||||
"inverters": {"inverter1": {"max_power_w": 10000}},
|
||||
"max_home_appliances": 0,
|
||||
},
|
||||
}
|
||||
@@ -49,20 +51,27 @@ def prepare_tariffs(config_eos):
|
||||
"feed_in_tariff_wh": revenues,
|
||||
}
|
||||
|
||||
async def read_array(key, **kwargs):
|
||||
async def read_series(key, **kwargs):
|
||||
if key == "feed_in_tariff_wh" and tariff_reader is not None:
|
||||
return await tariff_reader(key=key, **kwargs)
|
||||
value = arrays[key]
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
return np.asarray(value)
|
||||
return pd.Series(
|
||||
value, index=pd.date_range("2026-08-01T00:00:00Z", periods=len(value), freq="h")
|
||||
)
|
||||
|
||||
prediction = Mock(update_data=AsyncMock(), key_to_array=AsyncMock(side_effect=read_array))
|
||||
ems = Mock(start_datetime=to_datetime("2026-08-01T00:00:00+00:00"))
|
||||
prediction = Mock(
|
||||
update_data=AsyncMock(), key_to_raw_series=AsyncMock(side_effect=read_series)
|
||||
)
|
||||
ems = Mock(
|
||||
start_datetime=to_datetime("2026-08-01T00:00:00+00:00", in_timezone="UTC"),
|
||||
observation_datetime=to_datetime("2026-08-01T00:00:00+00:00", in_timezone="UTC"),
|
||||
)
|
||||
ems.genetic_solution.return_value = None
|
||||
with (
|
||||
patch("akkudoktoreos.optimization.genetic.geneticparams.get_ems", return_value=ems),
|
||||
patch.object(GeneticOptimizationParameters, "prediction", prediction),
|
||||
patch("akkudoktoreos.optimization.genetic.configrequest.get_ems", return_value=ems),
|
||||
patch.object(ConfigOptimizationRequest, "prediction", prediction),
|
||||
):
|
||||
parameters = await GeneticOptimizationParameters.prepare()
|
||||
return parameters, prices, prediction
|
||||
@@ -100,7 +109,7 @@ async def test_provider_revenues_survive_preparation_and_simulation(
|
||||
# No battery or household load is needed to expose tariff substitution/unit bugs.
|
||||
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=10000))
|
||||
inverter.self_consumption_predictor = Mock()
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.return_value = 1.0
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.return_value = 0.0
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
parameters.ems, optimization_hours=24, prediction_hours=24, inverter=inverter
|
||||
@@ -121,7 +130,6 @@ async def test_provider_revenues_survive_preparation_and_simulation(
|
||||
RuntimeError("import unavailable"),
|
||||
[],
|
||||
[0.00007] * 23,
|
||||
[0.00007] * 25,
|
||||
[np.nan] * 24,
|
||||
[0.00007] * 23 + [np.nan],
|
||||
[np.inf] * 24,
|
||||
@@ -152,14 +160,14 @@ def test_prediction_record_prices_are_already_per_wh():
|
||||
async def test_timestamped_import_records_are_read_in_order(prepare_tariffs, values):
|
||||
provider = FeedInTariffImport()
|
||||
provider._db_reset_state()
|
||||
start = to_datetime("2026-08-01T00:00:00+00:00").set(hour=0)
|
||||
start = to_datetime("2026-08-01T00:00:00+00:00", in_timezone="UTC").set(hour=0)
|
||||
try:
|
||||
await provider.key_from_series(
|
||||
"feed_in_tariff_wh",
|
||||
pd.Series(values, index=pd.date_range(start=start, periods=24, freq="h")),
|
||||
)
|
||||
parameters, _, _ = await prepare_tariffs(
|
||||
"FeedInTariffImport", [], tariff_reader=provider.key_to_array
|
||||
"FeedInTariffImport", [], tariff_reader=provider.key_to_raw_series
|
||||
)
|
||||
if values[0] is None:
|
||||
assert parameters is None
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Semantic report tests with synthetic data; no forecast provider or live EMS run."""
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import matplotlib.dates as mdates
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pendulum
|
||||
import pytest
|
||||
from pypdf import PdfReader
|
||||
|
||||
from akkudoktoreos.optimization.genetic import geneticvisualize as visualize
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import (
|
||||
TailDiagnostics,
|
||||
TailPlanSlot,
|
||||
TerminalValueCurve,
|
||||
TerminalValueResult,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def report_solution():
|
||||
start = pendulum.datetime(2026, 10, 25, 1, 45, tz="Europe/Berlin")
|
||||
energy = [100.0, 150.0, 200.0, 250.0]
|
||||
result = SimpleNamespace(
|
||||
load_wh_per_hour=energy,
|
||||
home_appliance_wh_per_hour=energy,
|
||||
grid_feed_in_wh_per_hour=energy,
|
||||
grid_consumption_wh_per_hour=energy,
|
||||
losses_per_hour=[1.0] * 4,
|
||||
battery_soc_per_hour=[20.0, 25.0, 30.0, 35.0],
|
||||
ev_soc_per_hour=[0.0] * 4,
|
||||
costs_per_hour=[0.03] * 4,
|
||||
revenue_per_hour=[0.01] * 4,
|
||||
total_costs=0.12,
|
||||
total_revenue=0.04,
|
||||
total_balance=0.08,
|
||||
home_appliance_energy_wh={"water-heater": energy},
|
||||
)
|
||||
return SimpleNamespace(
|
||||
parameters=SimpleNamespace(
|
||||
ems=SimpleNamespace(
|
||||
total_load=energy,
|
||||
pv_forecast_wh=energy,
|
||||
feed_in_tariff_per_wh=[0.00007] * 4,
|
||||
electricity_price_per_wh=[0.0003] * 4,
|
||||
),
|
||||
temperature_forecast=[12.0] * 4,
|
||||
),
|
||||
interval_seconds=900,
|
||||
start_solution_datetime=start,
|
||||
controls_start_at_now=True,
|
||||
start_hour=1,
|
||||
ac_charge=[0.0, 0.25, 0.5, 1.0],
|
||||
dc_charge=[1.0] * 4,
|
||||
discharge_allowed=[0, 0, 1, 1],
|
||||
battery_grid_export_allowed=[0, 0, 0, 1],
|
||||
battery_grid_export_factor=[0.0, 0.0, 0.0, 0.5],
|
||||
result=result,
|
||||
extra_data=None,
|
||||
fitness_history=None,
|
||||
fixed_seed=None,
|
||||
appliance_starts={"water-heater": [start.add(minutes=15)]},
|
||||
appliance_deadline_missed={"water-heater": True},
|
||||
terminal_value=TerminalValueResult(
|
||||
mode="TAIL", control_horizon_hours=1, requested_tail_hours=2,
|
||||
effective_tail_hours=0.25, tail_end_hour=1.25,
|
||||
battery_energy_wh=500, credited_euro=0.13,
|
||||
tail_operating_euro=0.1, continuation_value_euro=0.03,
|
||||
continuation_mode="AUTO", reason="forecast gap limits tail",
|
||||
curve=TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.26]),
|
||||
tail_diagnostics=TailDiagnostics(slots=1, slot_hours=0.25),
|
||||
tail_plan=[TailPlanSlot(
|
||||
slot=0, hour_from_start=1, action="discharge",
|
||||
soc_start_percentage=40, soc_end_percentage=35,
|
||||
pv_wh=0, load_wh=100, grid_import_wh=0, grid_export_wh=0,
|
||||
battery_charge_wh=0, battery_discharge_wh=100,
|
||||
import_price_euro_per_kwh=0.3, feed_in_tariff_euro_per_kwh=0.07,
|
||||
slot_value_euro=0.03, remaining_value_euro=0.1,
|
||||
ac_charge_factor=0, dc_charge_allowed=1, discharge_allowed=1,
|
||||
battery_grid_export_factor=0,
|
||||
)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interval_seconds", [900, 3600])
|
||||
def test_report_preserves_run_timing_energy_and_export_controls(
|
||||
config_eos, monkeypatch, report_solution, interval_seconds
|
||||
):
|
||||
report_solution.interval_seconds = interval_seconds
|
||||
captured = []
|
||||
original = visualize.GeneticVisualizationReport.create_line_chart_date
|
||||
|
||||
def capture(self, start_date, y_list, **kwargs):
|
||||
captured.append((start_date, y_list, kwargs, self.interval_seconds))
|
||||
return original(self, start_date, y_list, **kwargs)
|
||||
|
||||
monkeypatch.setattr(visualize.GeneticVisualizationReport, "create_line_chart_date", capture)
|
||||
monkeypatch.setattr(visualize, "get_ems", lambda: pytest.fail("Snapshot must own report timing"))
|
||||
pdf = visualize.genetic_prepare_visualize(report_solution)
|
||||
assert pdf.startswith(b"%PDF-")
|
||||
assert all(row[0] == report_solution.start_solution_datetime for row in captured)
|
||||
assert all(row[3] == interval_seconds for row in captured)
|
||||
load = next(row for row in captured if row[2].get("title") == "Load Profile")
|
||||
assert load[1] == [[100, 150, 200, 250]] # No repeated wall-clock trimming or Wh scaling.
|
||||
controls = next(row for row in captured if row[2].get("title") == "Executable Battery Controls")
|
||||
assert controls[1][-1] == [0, 0, 0, 0.5]
|
||||
assert len(controls[1]) == 5
|
||||
text = " ".join(page.extract_text() for page in PdfReader(BytesIO(pdf)).pages)
|
||||
for expected in (
|
||||
"Energy Flow per Interval", "water-heater", "Deadline missed: True",
|
||||
"forecast gap limits tail", "effective tail: 0.25 h", "0.13 EUR",
|
||||
"not executable controls", "not realized revenue", "Residual Battery Value Curve",
|
||||
):
|
||||
assert expected in text
|
||||
assert "Energy Flow per Hour" not in text
|
||||
|
||||
|
||||
def test_short_quarterhour_chart_has_elapsed_fractional_hours(config_eos):
|
||||
report = visualize.GeneticVisualizationReport(interval_seconds=900)
|
||||
start = pendulum.datetime(2026, 10, 25, 2, 45, tz="Europe/Berlin", fold=0)
|
||||
report.create_line_chart_date(start, [[100.0, 200.0, 300.0, 400.0]], ylabel="Wh")
|
||||
fig, axis = plt.subplots()
|
||||
try:
|
||||
report.current_group[0]()
|
||||
dates = mdates.num2date(axis.lines[0].get_xdata())
|
||||
assert np.diff([value.timestamp() for value in dates]).tolist() == [900] * 3
|
||||
assert np.asarray(axis.lines[0].get_ydata()).tolist() == [100, 200, 300, 400]
|
||||
assert [label.get_text() for label in fig.axes[1].get_xticklabels()] == ["0", "0.25", "0.5", "0.75"]
|
||||
finally:
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_empty_and_mismatched_date_series(config_eos):
|
||||
report = visualize.GeneticVisualizationReport()
|
||||
start = pendulum.now("UTC")
|
||||
report.create_line_chart_date(start, [[]], ylabel="Wh")
|
||||
assert report.current_group == []
|
||||
with pytest.raises(ValueError, match="same intervals"):
|
||||
report.create_line_chart_date(start, [[1.0, 2.0], [1.0]], ylabel="Wh")
|
||||
|
||||
|
||||
def test_existing_hourly_solution_renders_without_terminal_metadata(config_eos, monkeypatch):
|
||||
payload = (Path(__file__).parent / "testdata/genetic/optimize_result_1.json").read_text()
|
||||
solution = GeneticSolution.model_validate_json(payload)
|
||||
assert not solution.controls_start_at_now
|
||||
assert solution.terminal_value is None
|
||||
start = pendulum.datetime(2026, 9, 16, solution.start_hour, tz="UTC")
|
||||
monkeypatch.setattr(visualize, "get_ems", lambda: SimpleNamespace(start_datetime=start))
|
||||
pdf = visualize.genetic_prepare_visualize(solution)
|
||||
assert len(PdfReader(BytesIO(pdf)).pages) >= 4
|
||||
|
||||
|
||||
def test_fixed_fallback_does_not_invent_tail_charts(config_eos, report_solution):
|
||||
report_solution.terminal_value = TerminalValueResult(
|
||||
mode="FIXED", reason="No complete tail forecast", credited_euro=0.05,
|
||||
battery_energy_wh=500, requested_tail_hours=24,
|
||||
)
|
||||
report_solution.result.home_appliance_energy_wh = {}
|
||||
pdf = visualize.genetic_prepare_visualize(report_solution)
|
||||
text = " ".join(page.extract_text() for page in PdfReader(BytesIO(pdf)).pages)
|
||||
assert "No complete tail forecast" in text
|
||||
assert "effective tail: 0 h" in text
|
||||
assert "Deadline missed: True" in text # Report unscheduled consumers even with no energy series.
|
||||
assert "Residual Battery Value Curve" not in text
|
||||
assert "Tail Lookahead:" not in text
|
||||
|
||||
|
||||
def test_tail_chart_starts_after_control_horizon(config_eos, report_solution):
|
||||
report = visualize.GeneticVisualizationReport(interval_seconds=900)
|
||||
start = report_solution.start_solution_datetime
|
||||
visualize._add_solution_diagnostics(report, report_solution, start)
|
||||
fig, axis = plt.subplots()
|
||||
try:
|
||||
report.groups[-1][0]()
|
||||
expected = mdates.date2num(start.add(hours=1))
|
||||
assert np.asarray(axis.lines[0].get_xdata()).tolist() == [expected]
|
||||
assert np.asarray(axis.lines[3].get_ydata()).tolist() == [100]
|
||||
assert all(line.get_marker() == "o" for line in axis.lines)
|
||||
left, right = axis.get_xlim()
|
||||
assert left < expected < right
|
||||
assert (right - left) * 86400 == pytest.approx(900)
|
||||
assert "Not Executable" in axis.get_title()
|
||||
assert report_solution.discharge_allowed == [0, 0, 1, 1]
|
||||
finally:
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tariff", [0.00007, [0.00007] * 6])
|
||||
def test_pdf_clips_forecast_tail_and_accepts_historical_diagnostics(
|
||||
config_eos, monkeypatch, report_solution, tariff
|
||||
):
|
||||
report_solution.parameters.ems.total_load = [100.0] * 6
|
||||
report_solution.parameters.ems.pv_forecast_wh = [200.0] * 6
|
||||
report_solution.parameters.ems.feed_in_tariff_per_wh = tariff
|
||||
report_solution.extra_data = {
|
||||
"verluste": [1.0, 2.0, 3.0], "bilanz": [0.1, 0.2, 0.3],
|
||||
"nebenbedingung": [0.0, 0.0, 0.0],
|
||||
}
|
||||
captured = []
|
||||
original = visualize.GeneticVisualizationReport.create_line_chart_date
|
||||
|
||||
def capture(self, start_date, y_list, **kwargs):
|
||||
captured.append((y_list, kwargs))
|
||||
return original(self, start_date, y_list, **kwargs)
|
||||
|
||||
monkeypatch.setattr(visualize.GeneticVisualizationReport, "create_line_chart_date", capture)
|
||||
pdf = visualize.genetic_prepare_visualize(report_solution)
|
||||
assert pdf.startswith(b"%PDF-")
|
||||
tariff_plot = next(series for series, kwargs in captured if kwargs.get("title") == "Remuneration")
|
||||
assert np.asarray(tariff_plot[0]).tolist() == [0.00007] * 4
|
||||
assert all(len(series[0]) == 4 for series, _ in captured)
|
||||
assert "verluste" in report_solution.extra_data # Rendering must not mutate the retained run.
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Contracts shared by the configuration, tariff and optimizer PRs."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.optimization.genetic0.genetic0params import (
|
||||
Genetic0EnergyManagementParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model", [GeneticEnergyManagementParameters, Genetic0EnergyManagementParameters]
|
||||
)
|
||||
@pytest.mark.parametrize("tariff", [0.00008, [0.00008, -0.00002]])
|
||||
def test_energy_parameter_aliases_preserve_wh_prices(model, tariff):
|
||||
raw = {
|
||||
"pv_prognose_wh": [250.0, 0.0],
|
||||
"gesamtlast": [125.0, 125.0],
|
||||
"strompreis_euro_pro_wh": [0.0003, -0.0001],
|
||||
"einspeiseverguetung_euro_pro_wh": tariff,
|
||||
"preis_euro_pro_wh_akku": 0.00005,
|
||||
}
|
||||
params = model.model_validate(raw)
|
||||
data = params.model_dump(mode="json")
|
||||
pairs = {
|
||||
"pv_prognose_wh": "pv_forecast_wh",
|
||||
"gesamtlast": "total_load",
|
||||
"strompreis_euro_pro_wh": "electricity_price_per_wh",
|
||||
"einspeiseverguetung_euro_pro_wh": "feed_in_tariff_per_wh",
|
||||
"preis_euro_pro_wh_akku": "price_per_wh_battery",
|
||||
}
|
||||
for deprecated, canonical in pairs.items():
|
||||
assert data[deprecated] == data[canonical] == raw[deprecated]
|
||||
assert (
|
||||
model.model_validate({pairs[k]: v for k, v in raw.items()}).model_dump(mode="json") == data
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("algorithm", list(OptimizationAlgorithm))
|
||||
async def test_algorithm_result_endpoint_keeps_solution_types_separate(monkeypatch, algorithm):
|
||||
from akkudoktoreos.server import eos
|
||||
|
||||
genetic, genetic0 = object(), object()
|
||||
fake = SimpleNamespace(
|
||||
genetic_solution=Mock(return_value=genetic),
|
||||
genetic0_solution=Mock(return_value=genetic0),
|
||||
)
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: fake)
|
||||
monkeypatch.setattr(
|
||||
eos,
|
||||
"get_config",
|
||||
lambda: SimpleNamespace(optimization=SimpleNamespace(algorithms=["GENETIC", "GENETIC0"])),
|
||||
)
|
||||
result = await eos.fastapi_energy_management_optimization_solution_algorithm_get(algorithm)
|
||||
if algorithm == OptimizationAlgorithm.GENETIC:
|
||||
assert result is genetic
|
||||
fake.genetic_solution.assert_called_once_with()
|
||||
fake.genetic0_solution.assert_not_called()
|
||||
else:
|
||||
assert result is genetic0
|
||||
fake.genetic0_solution.assert_called_once_with()
|
||||
fake.genetic_solution.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("algorithm", ["genetic", "genetic0"])
|
||||
def test_device_maps_feed_algorithm_specific_converters(algorithm):
|
||||
from akkudoktoreos.devices.devices import DevicesCommonSettings
|
||||
|
||||
settings = DevicesCommonSettings.model_validate(
|
||||
{
|
||||
"batteries": {"storage": {"capacity_wh": 12000, "max_charge_power_w": 3200}},
|
||||
"electric_vehicles": {"car": {"capacity_wh": 60000, "max_charge_power_w": 7000}},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 5000}},
|
||||
"home_appliances": {
|
||||
"washer": {
|
||||
"consumption_wh": 1800,
|
||||
"duration_h": 2,
|
||||
"num_cycles": 2,
|
||||
"min_cycle_gap_h": 1,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert settings.batteries is not None
|
||||
assert settings.electric_vehicles is not None
|
||||
assert settings.inverters is not None
|
||||
battery = getattr(settings.batteries["storage"], "to_" + algorithm + "_pv_bat_param")()
|
||||
ev = getattr(settings.electric_vehicles["car"], "to_" + algorithm + "_ev_bat_param")()
|
||||
inverter = getattr(settings.inverters["inverter"], "to_" + algorithm + "_param")()
|
||||
appliance = getattr(settings.home_appliances["washer"], "to_" + algorithm + "_param")()
|
||||
assert (battery.device_id, ev.device_id, inverter.device_id, appliance.device_id) == (
|
||||
"storage",
|
||||
"car",
|
||||
"inverter",
|
||||
"washer",
|
||||
)
|
||||
assert battery.capacity_wh == 12000
|
||||
assert battery.max_charge_power_w == 3200
|
||||
assert ev.capacity_wh == 60000
|
||||
assert ev.max_charge_power_w == 7000
|
||||
assert inverter.max_power_wh == 5000
|
||||
assert inverter.battery_id == "storage"
|
||||
assert appliance.consumption_wh == 1800
|
||||
assert appliance.duration_h == 2
|
||||
assert settings.batteries["storage"].measurement_key_soc_factor == "storage-soc-factor"
|
||||
if algorithm == "genetic":
|
||||
assert appliance.num_cycles == 2
|
||||
assert appliance.min_cycle_gap_h == 1
|
||||
@@ -0,0 +1,78 @@
|
||||
"""The report endpoint selects retained results and renders outside the event loop."""
|
||||
|
||||
import threading
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pypdf import PdfReader
|
||||
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||||
from akkudoktoreos.server import eos
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retained_result():
|
||||
path = Path(__file__).parent / "testdata/genetic/optimize_result_1.json"
|
||||
solution = GeneticSolution.model_validate_json(path.read_text())
|
||||
solution.start_solution_datetime = to_datetime("2026-09-16T10:00:00+02:00")
|
||||
return solution
|
||||
|
||||
|
||||
def test_missing_algorithm_report_returns_404(config_eos, monkeypatch):
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(genetic_solution=lambda: None))
|
||||
response = TestClient(eos.app).get("/v1/energy-management/optimization/solution/GENETIC/pdf")
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
|
||||
|
||||
def test_retained_algorithm_result_renders_real_pdf(config_eos, monkeypatch, retained_result):
|
||||
solution = retained_result
|
||||
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(genetic_solution=lambda: solution))
|
||||
# A later run and changed configuration must not retime the retained report.
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.optimization.genetic import geneticvisualize
|
||||
|
||||
get_ems().set_start_datetime(to_datetime("2027-01-01T00:00:00+01:00"))
|
||||
config_eos.optimization.genetic.interval_sec = 900
|
||||
monkeypatch.setattr(
|
||||
geneticvisualize, "get_ems", lambda: pytest.fail("Report must retain its own time grid")
|
||||
)
|
||||
before = solution.model_dump_json()
|
||||
response = TestClient(eos.app).get("/v1/energy-management/optimization/solution/GENETIC/pdf")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
assert "genetic" in response.headers["content-disposition"]
|
||||
assert response.content.startswith(b"%PDF-")
|
||||
assert len(PdfReader(BytesIO(response.content)).pages) >= 4
|
||||
assert solution.model_dump_json() == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_is_offloaded_and_uses_an_owned_copy(config_eos, monkeypatch, retained_result):
|
||||
solution = retained_result
|
||||
caller_thread = threading.get_ident()
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
eos,
|
||||
"get_ems",
|
||||
lambda: SimpleNamespace(
|
||||
genetic_solution=lambda: solution, genetic0_solution=lambda: solution
|
||||
),
|
||||
)
|
||||
original_controls = list(solution.ac_charge)
|
||||
|
||||
def render(*, solution):
|
||||
captured.append((threading.get_ident(), solution))
|
||||
solution.ac_charge[0] = 0.123
|
||||
return b"%PDF-isolated-render"
|
||||
|
||||
monkeypatch.setattr(eos, "genetic_prepare_visualize", render)
|
||||
response = await eos.fastapi_energy_management_optimization_solution_genetic_pdf_get()
|
||||
assert response.body == b"%PDF-isolated-render"
|
||||
assert captured[0][0] != caller_thread
|
||||
assert captured[0][1] is not solution
|
||||
assert solution.ac_charge == original_controls
|
||||
@@ -22,6 +22,7 @@ def offline_ems(monkeypatch):
|
||||
cls = ems_module.EnergyManagement
|
||||
for name in (
|
||||
"_start_datetime",
|
||||
"_observation_datetime",
|
||||
"_last_run_datetime",
|
||||
"_plan",
|
||||
"_optimization_solution",
|
||||
@@ -34,10 +35,11 @@ def offline_ems(monkeypatch):
|
||||
monkeypatch.setattr(ems_module, "CacheEnergyManagementStore", Mock())
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
general=SimpleNamespace(timezone="Europe/Berlin"),
|
||||
ems=SimpleNamespace(mode=EnergyManagementMode.OPTIMIZATION),
|
||||
optimization=SimpleNamespace(
|
||||
algorithm=OptimizationAlgorithm.GENETIC,
|
||||
genetic=SimpleNamespace(generations=3, seed=17),
|
||||
genetic=SimpleNamespace(generations=3, seed=17, interval_sec=3600, individuals=31),
|
||||
genetic0=SimpleNamespace(generations=5, seed=29),
|
||||
),
|
||||
server=SimpleNamespace(verbose=False),
|
||||
@@ -48,6 +50,42 @@ def offline_ems(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("algorithm", list(OptimizationAlgorithm))
|
||||
@pytest.mark.parametrize("explicit_start", [False, True])
|
||||
async def test_implicit_genetic_start_uses_site_timezone(
|
||||
offline_ems, monkeypatch, set_other_timezone, algorithm, explicit_start
|
||||
):
|
||||
set_other_timezone("UTC")
|
||||
now = to_datetime("2026-09-16T22:47:23Z", in_timezone="UTC")
|
||||
|
||||
def frozen_datetime(value=None, **kwargs):
|
||||
return to_datetime(now if value is None else value, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ems_module, "to_datetime", frozen_datetime)
|
||||
offline_ems.config.optimization.genetic.interval_sec = 900
|
||||
await ems_module.EnergyManagement.run(
|
||||
offline_ems,
|
||||
mode=EnergyManagementMode.PREDICTION,
|
||||
algorithm=algorithm,
|
||||
start_datetime=now if explicit_start else None,
|
||||
)
|
||||
start = ems_module.EnergyManagement._start_datetime
|
||||
observed = ems_module.EnergyManagement._observation_datetime
|
||||
assert start is not None and observed is not None
|
||||
assert observed.timestamp() == now.timestamp()
|
||||
if algorithm == OptimizationAlgorithm.GENETIC:
|
||||
assert start.minute == 45
|
||||
assert start.hour == (22 if explicit_start else 0)
|
||||
assert start.day == (16 if explicit_start else 17)
|
||||
assert start.timezone_name == ("UTC" if explicit_start else "Europe/Berlin")
|
||||
else:
|
||||
assert start.minute == 0
|
||||
assert start.hour == 22
|
||||
assert start.day == 16
|
||||
assert start.timezone_name == "UTC"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("algorithm", list(OptimizationAlgorithm))
|
||||
@pytest.mark.parametrize("selection", ["configured", "explicit"])
|
||||
@@ -90,6 +128,8 @@ async def test_optimization_routes_only_selected_algorithm(
|
||||
kwargs[suffix + "_parameters"] = sentinel_parameters
|
||||
kwargs[suffix + "_generations"] = 7
|
||||
kwargs[suffix + "_seed"] = 43
|
||||
if algorithm == OptimizationAlgorithm.GENETIC:
|
||||
kwargs["genetic_individuals"] = 11
|
||||
run_result = await ems_module.EnergyManagement.run(offline_ems, **kwargs)
|
||||
assert run_result is solution
|
||||
selected = constructors[selected_name]
|
||||
@@ -97,11 +137,14 @@ async def test_optimization_routes_only_selected_algorithm(
|
||||
selected.assert_called_once_with(
|
||||
verbose=False, fixed_seed=43 if supplied else expected_config.seed
|
||||
)
|
||||
selected.return_value.optimize_ems.assert_called_once_with(
|
||||
start_hour=expected_hour,
|
||||
parameters=sentinel_parameters,
|
||||
ngen=7 if supplied else expected_config.generations,
|
||||
)
|
||||
expected_arguments: dict[str, Any] = {
|
||||
"start_hour": expected_hour,
|
||||
"parameters": sentinel_parameters,
|
||||
"ngen": 7 if supplied else expected_config.generations,
|
||||
}
|
||||
if algorithm == OptimizationAlgorithm.GENETIC:
|
||||
expected_arguments["individuals"] = 11 if supplied else None
|
||||
selected.return_value.optimize_ems.assert_called_once_with(**expected_arguments)
|
||||
other_name = "Genetic0" if selected_name == "Genetic" else "Genetic"
|
||||
constructors[other_name].assert_not_called()
|
||||
preparers[other_name].assert_not_awaited()
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Economic tail scenarios and hard control/forecast boundaries."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import SettingsEOSDefaults
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.tailvalue import build_tail_value_curve
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueCurve
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
def devices(power=1000, efficiency=1.0, lcos=0, ac_limit=None, export_power=5000):
|
||||
bat = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=1000,
|
||||
max_charge_power_w=power,
|
||||
charging_efficiency=efficiency,
|
||||
discharging_efficiency=efficiency,
|
||||
initial_soc_percentage=50,
|
||||
levelized_cost_of_storage_kwh=lcos,
|
||||
charge_rates=[0, 0.5, 1],
|
||||
),
|
||||
prediction_hours=1,
|
||||
)
|
||||
inv = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
battery_id="battery1",
|
||||
max_power_wh=export_power,
|
||||
dc_to_ac_efficiency=1,
|
||||
ac_to_dc_efficiency=1,
|
||||
max_ac_charge_power_w=ac_limit,
|
||||
),
|
||||
battery=bat,
|
||||
)
|
||||
return bat, inv
|
||||
|
||||
|
||||
def curve(
|
||||
prices=(-0.1, 0.3),
|
||||
tariffs=(0, 0.3),
|
||||
direct=True,
|
||||
continuation=None,
|
||||
load=None,
|
||||
pv=None,
|
||||
**kwargs,
|
||||
):
|
||||
bat, inv = devices(**kwargs)
|
||||
return build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array(prices) / 1000,
|
||||
feed_in_euro_per_wh=np.array(tariffs) / 1000,
|
||||
load_wh=np.zeros(len(prices)) if load is None else np.array(load),
|
||||
pv_wh=np.zeros(len(prices)) if pv is None else np.array(pv),
|
||||
continuation=continuation or TerminalValueCurve(),
|
||||
charge_rates=[0.5, 1],
|
||||
export_rates=[1],
|
||||
direct_marketing=direct,
|
||||
)
|
||||
|
||||
|
||||
def test_headroom_has_value_and_empty_state_can_earn():
|
||||
c = curve()
|
||||
assert c.value(0) == pytest.approx(0.4)
|
||||
tail, continuation = c.component_values(0)
|
||||
assert tail == pytest.approx(0.4)
|
||||
assert continuation == pytest.approx(0.0)
|
||||
assert c.value(0) == pytest.approx(tail + continuation)
|
||||
assert c.value(500) > c.value(1000)
|
||||
assert any(v < 0 for v in c.marginal_euro_per_kwh)
|
||||
|
||||
|
||||
def test_chronology_changes_arbitrage():
|
||||
forward = curve()
|
||||
reverse = curve(prices=(0.3, -0.1), tariffs=(0.3, 0))
|
||||
assert forward.value(0) > reverse.value(0)
|
||||
|
||||
|
||||
def test_discharge_and_ac_power_limits():
|
||||
limited = curve(prices=(1,), tariffs=(1,), power=100)
|
||||
assert limited.value(1000) == pytest.approx(0.1)
|
||||
limited_ac = curve(ac_limit=100)
|
||||
assert limited_ac.value(0) == pytest.approx(0.04)
|
||||
limited_inverter = curve(prices=(1,), tariffs=(1,), export_power=50)
|
||||
assert limited_inverter.value(1000) == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_losses_and_lcos_reduce_arbitrage():
|
||||
ideal = curve(prices=(0.1, 0.3))
|
||||
lossy = curve(prices=(0.1, 0.3), efficiency=0.8)
|
||||
assert 0 < lossy.value(0) < ideal.value(0)
|
||||
assert curve(prices=(0.1, 0.3), lcos=0.25).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_no_battery_export_without_permission():
|
||||
assert curve(prices=(0.1, 0.3), direct=False).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_pv_surplus_can_be_stored_for_local_load():
|
||||
c = curve(prices=(0.2, 0.3), tariffs=(0, 0), pv=[1000, 0], load=[0, 1000], direct=False)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
# Without PV the same empty battery must buy energy to serve the load.
|
||||
assert curve(prices=(0.2, 0.3), tariffs=(0, 0), load=[0, 1000], direct=False).value(
|
||||
0
|
||||
) < c.value(0)
|
||||
|
||||
|
||||
def test_continuation_survives_tail_end():
|
||||
continuation = TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.2])
|
||||
c = curve(prices=(0.5,), tariffs=(0,), continuation=continuation)
|
||||
assert c.value(1000) == pytest.approx(0.2)
|
||||
tail, continuation_credit = c.component_values(1000)
|
||||
assert tail == pytest.approx(0.0)
|
||||
assert continuation_credit == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_tail_diagnostic_plan_explains_the_selected_path():
|
||||
c = curve()
|
||||
plan = c.diagnostic_plan(0, control_horizon_hours=24)
|
||||
assert len(plan) == 2
|
||||
assert plan[0].hour_from_start == 24
|
||||
assert plan[0].action == "GRID_CHARGE"
|
||||
assert plan[0].soc_end_percentage > plan[0].soc_start_percentage
|
||||
assert plan[0].grid_import_wh > 0
|
||||
assert plan[1].action == "BATTERY_EXPORT"
|
||||
assert plan[1].soc_end_percentage < plan[1].soc_start_percentage
|
||||
assert plan[1].grid_export_wh > 0
|
||||
assert sum(slot.slot_value_euro for slot in plan) == pytest.approx(c.value(0))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_values_are_not_extrapolated():
|
||||
from types import SimpleNamespace
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
series = pd.Series([1.0, 2.0], index=pd.date_range(start=start, periods=2, freq="h"))
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
provider = SimpleNamespace(key_to_raw_series=AsyncMock(return_value=series))
|
||||
result = await bounded_forecast_array(
|
||||
provider,
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=3),
|
||||
interval=to_duration("15 minutes"),
|
||||
)
|
||||
assert result[:8].tolist() == [1.0] * 4 + [2.0] * 4
|
||||
assert np.isnan(result[8:]).all()
|
||||
|
||||
|
||||
def test_disabled_ac_conversion_cannot_earn_negative_price_revenue():
|
||||
bat, inv = devices()
|
||||
inv.parameters.ac_to_dc_efficiency = 0
|
||||
c = build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array([-0.001, 0.001]),
|
||||
feed_in_euro_per_wh=np.array([0.0, 0.001]),
|
||||
load_wh=np.zeros(2),
|
||||
pv_wh=np.zeros(2),
|
||||
continuation=TerminalValueCurve(),
|
||||
charge_rates=[1],
|
||||
export_rates=[1],
|
||||
direct_marketing=True,
|
||||
)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
assert bat.soc_wh == 500 # Building the tail never mutates the real battery.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_provider_key_stays_missing():
|
||||
from types import SimpleNamespace
|
||||
|
||||
async def unavailable(*a, **kw):
|
||||
raise KeyError("price unavailable")
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
result = await bounded_forecast_array(
|
||||
SimpleNamespace(key_to_raw_series=unavailable),
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=2),
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
assert np.isnan(result).all()
|
||||
assert len(result) == 2
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for the concave terminal value of the energy left in the battery."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import (
|
||||
build_terminal_value_curve,
|
||||
trailing_window,
|
||||
)
|
||||
|
||||
|
||||
def _curve(**overrides):
|
||||
"""Two expensive slots, one cheap one, no PV, 10 kWh of usable battery."""
|
||||
params: dict[str, Any] = dict(
|
||||
prices_euro_per_wh=np.array([0.0004, 0.0003, 0.0001]),
|
||||
load_wh=np.array([1000.0, 1000.0, 1000.0]),
|
||||
pv_wh=np.array([0.0, 0.0, 0.0]),
|
||||
feed_in_euro_per_wh=np.array([0.00008, 0.00008, 0.00008]),
|
||||
max_energy_wh=10000.0,
|
||||
lcos_euro_per_kwh=0.0,
|
||||
dc_to_ac_efficiency=1.0,
|
||||
grid_export_allowed=False,
|
||||
)
|
||||
params.update(overrides)
|
||||
return build_terminal_value_curve(**params)
|
||||
|
||||
|
||||
def test_marginal_value_follows_the_most_expensive_hours_first():
|
||||
"""The first stored kWh replaces the most expensive slot, then the next."""
|
||||
curve = _curve()
|
||||
|
||||
# 0.40, 0.30 and 0.10 EUR/kWh, in that order.
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4, 0.3, 0.1])
|
||||
assert curve.energy_wh == pytest.approx([0.0, 1000.0, 2000.0, 3000.0])
|
||||
assert curve.value_euro == pytest.approx([0.0, 0.4, 0.7, 0.8])
|
||||
|
||||
|
||||
def test_curve_is_concave_and_saturates():
|
||||
"""Marginal values only decrease, and beyond the last breakpoint nothing is added."""
|
||||
curve = _curve()
|
||||
marginals = curve.marginal_euro_per_kwh
|
||||
|
||||
assert all(a >= b for a, b in zip(marginals, marginals[1:]))
|
||||
# The residual load of the window is 3 kWh - more energy replaces nothing.
|
||||
assert curve.value(3000.0) == pytest.approx(0.8)
|
||||
assert curve.value(9000.0) == pytest.approx(0.8)
|
||||
|
||||
|
||||
def test_value_interpolates_within_a_segment():
|
||||
"""Half of the first slot is worth half of the first segment."""
|
||||
curve = _curve()
|
||||
assert curve.value(500.0) == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_pv_reduces_the_residual_load():
|
||||
"""Only load that PV cannot cover can be replaced by stored energy."""
|
||||
curve = _curve(pv_wh=np.array([600.0, 1000.0, 0.0]))
|
||||
|
||||
# Slot 0 keeps 400 Wh, slot 1 is fully covered by PV, slot 2 keeps 1000 Wh.
|
||||
assert curve.energy_wh == pytest.approx([0.0, 400.0, 1400.0])
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4, 0.1])
|
||||
|
||||
|
||||
def test_lcos_is_subtracted_from_the_marginal_value():
|
||||
"""Storage cost is already charged on discharge and must not be credited twice."""
|
||||
curve = _curve(lcos_euro_per_kwh=0.05, dc_to_ac_efficiency=1.0)
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.35, 0.25, 0.05])
|
||||
|
||||
|
||||
def test_negative_prices_do_not_create_value():
|
||||
"""Storing energy for an hour that pays nothing is not worth anything."""
|
||||
curve = _curve(prices_euro_per_wh=np.array([0.0004, -0.0001, 0.0]))
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4])
|
||||
assert curve.value(5000.0) == pytest.approx(0.4)
|
||||
|
||||
|
||||
def test_export_tail_only_with_direct_marketing():
|
||||
"""Surplus beyond the residual load is worth an export - if export is allowed."""
|
||||
without = _curve(grid_export_allowed=False)
|
||||
with_export = _curve(grid_export_allowed=True)
|
||||
|
||||
assert without.value(10000.0) == pytest.approx(0.8)
|
||||
# 7 kWh beyond the residual load at the median feed-in tariff of 0.08 EUR/kWh.
|
||||
assert with_export.value(10000.0) == pytest.approx(0.8 + 7.0 * 0.08)
|
||||
assert with_export.marginal_euro_per_kwh[-1] == pytest.approx(0.08)
|
||||
|
||||
|
||||
def test_residual_energy_marks_the_knee():
|
||||
"""The knee separates load-backed value from the export tail."""
|
||||
without = _curve(grid_export_allowed=False)
|
||||
with_export = _curve(grid_export_allowed=True)
|
||||
|
||||
# 3 kWh of residual load in the window, whether or not export is allowed.
|
||||
assert without.residual_energy_wh == pytest.approx(3000.0)
|
||||
assert with_export.residual_energy_wh == pytest.approx(3000.0)
|
||||
# Only the export tail reaches beyond it.
|
||||
assert without.energy_wh[-1] == pytest.approx(3000.0)
|
||||
assert with_export.energy_wh[-1] == pytest.approx(10000.0)
|
||||
|
||||
|
||||
def test_curve_is_capped_by_the_usable_battery_energy():
|
||||
"""A battery smaller than the residual load ends the curve early."""
|
||||
curve = _curve(max_energy_wh=1500.0)
|
||||
assert curve.energy_wh[-1] == pytest.approx(1500.0)
|
||||
assert curve.value(5000.0) == pytest.approx(0.4 + 0.5 * 0.3)
|
||||
|
||||
|
||||
def test_empty_window_yields_an_empty_curve():
|
||||
"""Without data there is no curve, and no credit."""
|
||||
curve = build_terminal_value_curve(
|
||||
prices_euro_per_wh=np.zeros(0),
|
||||
load_wh=np.zeros(0),
|
||||
pv_wh=np.zeros(0),
|
||||
feed_in_euro_per_wh=np.zeros(0),
|
||||
max_energy_wh=10000.0,
|
||||
)
|
||||
assert curve.energy_wh == []
|
||||
assert curve.value(5000.0) == 0.0
|
||||
|
||||
|
||||
def test_trailing_window_takes_the_end_of_the_horizon():
|
||||
values = np.arange(10, dtype=float)
|
||||
|
||||
assert list(trailing_window(values, end_slot=8, window_slots=3)) == [5.0, 6.0, 7.0]
|
||||
# A window longer than the horizon yields what there is.
|
||||
assert list(trailing_window(values, end_slot=2, window_slots=5)) == [0.0, 1.0]
|
||||
assert list(trailing_window(None, end_slot=8, window_slots=3)) == []
|
||||
@@ -130,11 +130,17 @@ def test_backup_operations_report_an_uninitialized_path_as_an_invariant_failure(
|
||||
|
||||
|
||||
def test_energy_management_initializes_its_start_datetime_once(
|
||||
config_eos: ConfigEOS,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
clock = MagicMock(return_value=to_datetime("2024-01-01T12:34:56+01:00"))
|
||||
monkeypatch.setattr("akkudoktoreos.core.ems.to_datetime", clock)
|
||||
|
||||
def fixed_datetime(*args, **kwargs):
|
||||
return to_datetime(*args, **kwargs) if args or kwargs else clock()
|
||||
|
||||
monkeypatch.setattr("akkudoktoreos.core.ems.to_datetime", fixed_datetime)
|
||||
monkeypatch.setattr(EnergyManagement, "_start_datetime", None)
|
||||
monkeypatch.setattr(EnergyManagement, "_observation_datetime", None)
|
||||
ems = EnergyManagement()
|
||||
|
||||
first = ems.start_datetime
|
||||
@@ -159,7 +165,7 @@ async def test_energy_calculation_requires_resolvable_record_times(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
measurement = Measurement()
|
||||
measurement.records = [MeasurementDataRecord()]
|
||||
monkeypatch.setattr(measurement, "records", [MeasurementDataRecord()])
|
||||
monkeypatch.setattr(Measurement, "min_datetime", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(Measurement, "max_datetime", AsyncMock(return_value=None))
|
||||
with pytest.raises(ValueError, match="Start and end datetimes are required"):
|
||||
|
||||
+41
-9
@@ -12,14 +12,14 @@ config path from ``self.device_id`` without needing an external index.
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| batteries | `EOS_DEVICES__BATTERIES` | `dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings] | None` | `rw` | `None` | Stationary battery storage devices, keyed by device_id. |
|
||||
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings] | None` | `rw` | `None` | Electric vehicle battery packs, keyed by device_id. |
|
||||
| batteries | `EOS_DEVICES__BATTERIES` | `Optional[dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings]]` | `rw` | `None` | Stationary battery storage devices, keyed by device_id. |
|
||||
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings]]` | `rw` | `None` | Electric vehicle battery packs, keyed by device_id. |
|
||||
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `dict[str, akkudoktoreos.devices.settings.homeappliancesettings.HomeApplianceCommonSettings]` | `rw` | `required` | Shiftable home appliance devices, keyed by device_id. |
|
||||
| inverters | `EOS_DEVICES__INVERTERS` | `dict[str, akkudoktoreos.devices.settings.invertersettings.InverterCommonSettings] | None` | `rw` | `None` | Inverter devices, keyed by device_id. |
|
||||
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `int | None` | `rw` | `None` | Maximum number of batteries allowed. |
|
||||
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `int | None` | `rw` | `None` | Maximum number of EVs allowed. |
|
||||
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `int | None` | `rw` | `None` | Maximum number of home appliances allowed. |
|
||||
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `int | None` | `rw` | `None` | Maximum number of inverters allowed. |
|
||||
| inverters | `EOS_DEVICES__INVERTERS` | `Optional[dict[str, akkudoktoreos.devices.settings.invertersettings.InverterCommonSettings]]` | `rw` | `None` | Inverter devices, keyed by device_id. |
|
||||
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries allowed. |
|
||||
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of EVs allowed. |
|
||||
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home appliances allowed. |
|
||||
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters allowed. |
|
||||
| measurement_keys | | `list[str]` | `ro` | `N/A` | All measurement keys across all configured devices. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
@@ -35,6 +35,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"batteries": {
|
||||
"bat0": {
|
||||
"device_id": "bat0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 8000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -55,13 +57,21 @@ config path from ``self.device_id`` without needing an external index.
|
||||
1.0
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_batteries": 1,
|
||||
"electric_vehicles": {
|
||||
"ev0": {
|
||||
"device_id": "ev0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -82,7 +92,13 @@ config path from ``self.device_id`` without needing an external index.
|
||||
1.0
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_electric_vehicles": 1,
|
||||
@@ -116,6 +132,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"batteries": {
|
||||
"bat0": {
|
||||
"device_id": "bat0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 8000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -137,6 +155,12 @@ config path from ``self.device_id`` without needing an external index.
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
],
|
||||
"measurement_key_soc_factor": "bat0-soc-factor",
|
||||
"measurement_key_power_l1_w": "bat0-power-l1-w",
|
||||
"measurement_key_power_l2_w": "bat0-power-l2-w",
|
||||
@@ -155,6 +179,8 @@ config path from ``self.device_id`` without needing an external index.
|
||||
"electric_vehicles": {
|
||||
"ev0": {
|
||||
"device_id": "ev0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -176,6 +202,12 @@ config path from ``self.device_id`` without needing an external index.
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
],
|
||||
"measurement_key_soc_factor": "ev0-soc-factor",
|
||||
"measurement_key_power_l1_w": "ev0-power-l1-w",
|
||||
"measurement_key_power_l2_w": "ev0-power-l2-w",
|
||||
|
||||
+21
-2
@@ -39,6 +39,8 @@
|
||||
"batteries": {
|
||||
"bat0": {
|
||||
"device_id": "bat0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 8000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -59,13 +61,21 @@
|
||||
1.0
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_batteries": 1,
|
||||
"electric_vehicles": {
|
||||
"ev0": {
|
||||
"device_id": "ev0",
|
||||
"capacity_estimation": null,
|
||||
"capacity_estimate": null,
|
||||
"capacity_wh": 60000,
|
||||
"charging_efficiency": 0.88,
|
||||
"discharging_efficiency": 0.88,
|
||||
@@ -86,7 +96,13 @@
|
||||
1.0
|
||||
],
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100
|
||||
"max_soc_percentage": 100,
|
||||
"grid_export_rates": [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"max_electric_vehicles": 1,
|
||||
@@ -205,6 +221,9 @@
|
||||
},
|
||||
"measurement": {
|
||||
"historic_hours": 17520,
|
||||
"channels": {},
|
||||
"household": null,
|
||||
"energy_context_seconds": 86400,
|
||||
"load_emr_keys": [
|
||||
"load0_emr"
|
||||
],
|
||||
|
||||
+14
-5
@@ -7,12 +7,15 @@
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| grid_export_emr_keys | `EOS_MEASUREMENT__GRID_EXPORT_EMR_KEYS` | `list[str] | None` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy export to grid [kWh]. |
|
||||
| grid_import_emr_keys | `EOS_MEASUREMENT__GRID_IMPORT_EMR_KEYS` | `list[str] | None` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy import from grid [kWh]. |
|
||||
| historic_hours | `EOS_MEASUREMENT__HISTORIC_HOURS` | `int | None` | `rw` | `17520` | Number of hours into the past for measurement data |
|
||||
| channels | `EOS_MEASUREMENT__CHANNELS` | `dict[str, akkudoktoreos.measurement.measurement.MeasurementChannelSettings]` | `rw` | `required` | Typed raw measurement channels keyed by measurement key. |
|
||||
| energy_context_seconds | `EOS_MEASUREMENT__ENERGY_CONTEXT_SECONDS` | `int` | `rw` | `86400` | None |
|
||||
| grid_export_emr_keys | `EOS_MEASUREMENT__GRID_EXPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy export to grid [kWh]. |
|
||||
| grid_import_emr_keys | `EOS_MEASUREMENT__GRID_IMPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy import from grid [kWh]. |
|
||||
| historic_hours | `EOS_MEASUREMENT__HISTORIC_HOURS` | `Optional[int]` | `rw` | `17520` | Number of hours into the past for measurement data |
|
||||
| household | `EOS_MEASUREMENT__HOUSEHOLD` | `Optional[akkudoktoreos.measurement.household.HouseholdSettings]` | `rw` | `None` | Optional household energy balance definition. |
|
||||
| keys | | `list[str]` | `ro` | `N/A` | The keys of the measurements that can be stored. |
|
||||
| load_emr_keys | `EOS_MEASUREMENT__LOAD_EMR_KEYS` | `list[str] | None` | `rw` | `None` | The keys of the measurements that are energy meter readings of a load [kWh]. |
|
||||
| pv_production_emr_keys | `EOS_MEASUREMENT__PV_PRODUCTION_EMR_KEYS` | `list[str] | None` | `rw` | `None` | The keys of the measurements that are PV production energy meter readings [kWh]. |
|
||||
| load_emr_keys | `EOS_MEASUREMENT__LOAD_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of a load [kWh]. |
|
||||
| pv_production_emr_keys | `EOS_MEASUREMENT__PV_PRODUCTION_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are PV production energy meter readings [kWh]. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -25,6 +28,9 @@
|
||||
{
|
||||
"measurement": {
|
||||
"historic_hours": 17520,
|
||||
"channels": {},
|
||||
"household": null,
|
||||
"energy_context_seconds": 86400,
|
||||
"load_emr_keys": [
|
||||
"load0_emr"
|
||||
],
|
||||
@@ -51,6 +57,9 @@
|
||||
{
|
||||
"measurement": {
|
||||
"historic_hours": 17520,
|
||||
"channels": {},
|
||||
"household": null,
|
||||
"energy_context_seconds": 86400,
|
||||
"load_emr_keys": [
|
||||
"load0_emr"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user