Files
EOS/tests/test_geneticconfigoptimizerequest.py
T

838 lines
35 KiB
Python
Raw Normal View History

"""Configuration-owned requests and the real automatic GENETIC path."""
from types import SimpleNamespace
2026-11-10 17:56:13 +00:00
from typing import Any, Optional
from unittest.mock import AsyncMock, Mock, patch
import httpx
import numpy as np
import pandas as pd
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from pydantic import ValidationError
from akkudoktoreos.config.configmigrate import migrate_config_data
from akkudoktoreos.core.coreabc import get_measurement
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.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.utils.datetimeutil import to_datetime
@pytest_asyncio.fixture
async def isolated_measurement(config_eos):
"""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)
@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": {
2026-11-09 19:11:15 +00:00
"profile_adjustment": {"nominal_capacity_wh": 2000,
"max_charge_power_w": 1000},
"levelized_cost_of_storage_amt_kwh": 0.01,
}
},
"electric_vehicles": {},
2026-11-10 17:56:13 +00:00
"inverters": {
"inverter": {
"battery_id": "storage",
"profile_adjustment": {"nominal_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
@pytest.mark.parametrize("interval", [900, 3600])
async def test_forecastsolar_resolves_without_runtime_override(
configured_request, monkeypatch, interval
):
config, ems, _, data = configured_request
config.merge_settings_from_dict(
{
"general": {"latitude": 52.5, "longitude": 13.4, "timezone": "Europe/Berlin"},
"prediction": {"hours": 48},
"optimization": {"genetic": {"interval_sec": interval, "horizon_hours": 24}},
"pvforecast": {
"provider": "PVForecastForecastSolar",
"planes": [
{"surface_tilt": 45, "surface_azimuth": 217, "peakpower": 8.05},
],
},
}
)
ems.start_datetime = to_datetime("2026-11-02T10:00:00+01:00", in_timezone="Europe/Berlin")
ems.observation_datetime = ems.start_datetime
data["forecasts"] = {}
pv = PVForecastForecastSolar(config=config.load)
watts = {
f"2026-11-{day} {time}": power
for day in ("02", "03")
for time, power in {
"07:16:18": 0,
"08:00:00": 1000,
"09:00:00": 1000,
"10:00:00": 1000,
"11:00:00": 1000,
"12:00:00": 1000,
"13:00:00": 1000,
"14:00:00": 1000,
"15:00:00": 1000,
"16:00:00": 1000,
"16:55:54": 0,
}.items()
}
async def read(key, **kwargs):
if key == "pvforecast_ac_power":
return await pv.key_to_raw_series(key, **kwargs)
value = {
"loadforecast_power_w": 2000,
"elecprice_marketprice_wh": 0.0003,
"feed_in_tariff_wh": 0.00008,
}[key]
return pd.Series(
[value] * 72, index=pd.date_range("2026-11-02T00:00:00+01:00", periods=72, freq="h")
)
async def update():
# Isolate this synthetic response from the provider's on-disk cache.
await pv._update_data(force_update=True)
prediction = SimpleNamespace(update_data=AsyncMock(side_effect=update), key_to_raw_series=read)
monkeypatch.setattr(ConfigOptimizationRequest, "prediction", prediction)
response = Mock()
response.json.return_value = {
"result": {"watts": watts},
"message": {"info": {"timezone": "Europe/Berlin"}},
}
with patch("requests.get", return_value=response):
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
prediction.update_data.assert_awaited_once()
energy = parameters.ems.pv_forecast_wh
assert energy[10 * 3600 // interval] == pytest.approx(1000 * interval / 3600)
assert energy[17 * 3600 // interval : 24 * 3600 // interval] == [0] * (7 * 3600 // interval)
assert np.isfinite(energy[10 * 3600 // interval : 34 * 3600 // interval]).all()
await pv.delete_by_datetime()
@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, np.inf), (1, -0.1), (1, 1.1)],
)
async def test_stale_future_or_invalid_soc_defaults_to_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],
)
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 0
@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_defaults_to_zero(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],
)
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 0
@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") as error:
await ConfigOptimizationRequest.model_validate(data).resolve()
assert "2026-09-12T10:30:00+00:00, 2026-09-12T10:45:00+00:00" in str(error.value)
assert "1 invalid interval(s)" in str(error.value)
@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
@pytest.mark.parametrize("missing_soc", [False, True])
async def test_http_real_genetic_result_serializes_native_quarter_hour_contract(
configured_request, monkeypatch, missing_soc
):
from akkudoktoreos.server import eos
_, fake, _, data = configured_request
if missing_soc:
data["soc"] = {}
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"
assert result["parameters"]["pv_battery"]["initial_soc_percentage"] == (
0 if missing_soc else 42
)
@pytest.mark.asyncio
@pytest.mark.parametrize("missing_key", [False, True])
async def test_missing_soc_defaults_to_zero_without_mutating_inputs(
configured_request, missing_key, caplog
):
config, _, measurement, data = configured_request
data["soc"] = {}
before = config.model_dump_json()
if missing_key:
measurement.key_to_lists.side_effect = KeyError("SoC channel unavailable")
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 0
assert config.model_dump_json() == before
assert "assuming 0% initial SoC" in caplog.text
assert "not a measurement" in caplog.text
measurement.update_value.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("soc", [0, 42, 100])
async def test_explicit_soc_overrides_measurements(configured_request, soc):
_, _, measurement, data = configured_request
data["soc"] = {"storage": soc}
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == soc
measurement.key_to_lists.assert_not_awaited()
@pytest.mark.asyncio
async def test_missing_ev_soc_does_not_replace_explicit_battery_soc(configured_request):
config, _, _, data = configured_request
config.merge_settings_from_dict(
{"devices": {"max_electric_vehicles": 1,
2026-11-09 19:11:15 +00:00
"electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000}}}}}
)
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.pv_battery is not None
assert parameters.ev is not None
assert parameters.pv_battery.initial_soc_percentage == 42
assert parameters.ev.initial_soc_percentage == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("soc", [70, 100])
async def test_http_config_and_real_optimizer_allow_full_ev_target(configured_request,
monkeypatch, soc):
from akkudoktoreos.server import eos
config, fake, _, data = configured_request
config.merge_settings_from_dict({"devices": {"max_electric_vehicles": 1,
2026-11-09 19:11:15 +00:00
"electric_vehicles": {"ev": {"profile_adjustment": {"nominal_capacity_wh": 1000,
"max_charge_power_w": 1000}, "min_soc_percentage": 0,
"max_soc_percentage": 100}}}})
data["soc"]["ev"] = soc
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)
from types import SimpleNamespace
monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(run=run))
monkeypatch.setattr(eos, "get_config", lambda: config)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=eos.app),
base_url="http://test") as client:
updated = await client.put("/v1/config/devices/electric_vehicles/ev/min_soc_percentage",
json=100)
assert updated.status_code == 200, updated.text
assert config.devices.electric_vehicles["ev"].min_soc_percentage == 100
result = await client.post("/v1/optimize", json=data)
assert result.status_code == 200, result.text
plan = result.json()
assert plan["parameters"]["ev"]["min_soc_percentage"] == 100
assert plan["parameters"]["ev"]["initial_soc_percentage"] == soc
assert len(plan["result"]["ev_soc_per_hour"]) == 4
assert all(0 <= value <= 100 for value in plan["result"]["ev_soc_per_hour"])
assert plan["result"]["ev_soc_per_hour"][-1] == pytest.approx(100)
@pytest.mark.asyncio
@pytest.mark.parametrize("age_seconds", [901, 86400, 365 * 86400])
async def test_explicit_ev_soc_has_no_measurement_age_limit(configured_request, age_seconds):
config, ems, measurement, data = configured_request
config.merge_settings_from_dict(
{"devices": {"max_electric_vehicles": 1,
2026-11-09 19:11:15 +00:00
"electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000},
"min_soc_percentage": 100,
"max_soc_percentage": 100}}}}
)
old_sample = ems.observation_datetime.subtract(seconds=age_seconds)
measurement.key_to_lists.return_value = ([old_sample], [0.35])
data["soc"]["car"] = 70
config_before = config.model_dump_json()
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.ev is not None
assert parameters.ev.initial_soc_percentage == 70
assert parameters.ev.min_soc_percentage == 100
measurement.key_to_lists.assert_not_awaited()
measurement.update_value.assert_not_called()
assert config.model_dump_json() == config_before
@pytest.mark.asyncio
async def test_home_assistant_soc_read_mid_slot_is_fresh_for_resolver(
configured_request, monkeypatch, isolated_measurement
):
"""Adapter measurements carry the run time, not the rounded slot start (#1447)."""
from akkudoktoreos.adapter.homeassistant import HomeAssistantAdapter
config, _, _, data = configured_request
data["soc"] = {}
config.merge_settings_from_dict(
{
"adapter": {
"homeassistant": {
"device_measurement_entity_ids": {"storage-soc-factor": "sensor.akku_soc"}
}
}
}
)
monkeypatch.setattr(EnergyManagement, "_start_datetime", None)
monkeypatch.setattr(EnergyManagement, "_observation_datetime", None)
monkeypatch.setattr(EnergyManagement, "_stage", EnergyManagementStage.DATA_ACQUISITION)
ems = EnergyManagement()
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
# Run 11 minutes into the 10:15 slot: older than measurement_max_age_seconds (300 s)
# if the reading were stamped with the slot start.
run_time = to_datetime("2026-09-12T10:26:00Z", in_timezone="UTC")
ems.set_start_datetime(run_time, interval_seconds=900)
assert ems.start_datetime == run_time.subtract(minutes=11)
monkeypatch.setattr(ConfigOptimizationRequest, "measurement", isolated_measurement)
monkeypatch.setattr(HomeAssistantAdapter, "get_entity_state", lambda self, entity_id: "0.42")
await HomeAssistantAdapter()._update_data()
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 42
@pytest.mark.asyncio
async def test_meter_only_adapter_reading_does_not_mask_api_soc(
configured_request, monkeypatch, isolated_measurement
):
"""A newer record without SoC (meter reading) must not hide a fresh SoC (#1447)."""
from akkudoktoreos.adapter.homeassistant import HomeAssistantAdapter
config, _, _, data = configured_request
data["soc"] = {}
config.merge_settings_from_dict(
{"adapter": {"homeassistant": {"load_emr_entity_ids": ["sensor.load_meter"]}}}
)
monkeypatch.setattr(EnergyManagement, "_start_datetime", None)
monkeypatch.setattr(EnergyManagement, "_observation_datetime", None)
monkeypatch.setattr(EnergyManagement, "_stage", EnergyManagementStage.DATA_ACQUISITION)
ems = EnergyManagement()
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
# Run one minute into the 10:15 slot: the adapter's meter reading (slot start)
# is newer than the SoC supplied four minutes earlier, still within 300 s.
run_time = to_datetime("2026-09-12T10:16:00Z", in_timezone="UTC")
ems.set_start_datetime(run_time, interval_seconds=900)
measurement = isolated_measurement
monkeypatch.setattr(ConfigOptimizationRequest, "measurement", measurement)
# SoC arrives through the measurement API, the meter through Home Assistant.
await measurement.update_value(run_time.subtract(minutes=4), "storage-soc-factor", 0.42)
monkeypatch.setattr(HomeAssistantAdapter, "get_entity_state", lambda self, entity_id: "1234.5")
await HomeAssistantAdapter()._update_data()
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 42
@pytest.mark.asyncio
async def test_home_assistant_meter_readings_stay_on_slot_start(
configured_request, monkeypatch, isolated_measurement
):
"""Meter readings keep the slot start: energy is aggregated per slot (#1447)."""
from akkudoktoreos.adapter.homeassistant import HomeAssistantAdapter
config, _, _, _ = configured_request
config.merge_settings_from_dict(
{"adapter": {"homeassistant": {"load_emr_entity_ids": ["sensor.load_meter"]}}}
)
monkeypatch.setattr(EnergyManagement, "_start_datetime", None)
monkeypatch.setattr(EnergyManagement, "_observation_datetime", None)
monkeypatch.setattr(EnergyManagement, "_stage", EnergyManagementStage.DATA_ACQUISITION)
ems = EnergyManagement()
run_time = to_datetime("2026-09-12T10:26:00Z", in_timezone="UTC")
ems.set_start_datetime(run_time, interval_seconds=900)
monkeypatch.setattr(
HomeAssistantAdapter, "get_entity_state", lambda self, entity_id: "1234.5"
)
await HomeAssistantAdapter()._update_data()
dates, values = await isolated_measurement.key_to_lists(
key="sensor.load_meter",
start_datetime=run_time.subtract(hours=1),
end_datetime=run_time.add(hours=1),
)
assert values == [1234.5]
assert dates[0].timestamp() == ems.start_datetime.timestamp()
@pytest.mark.asyncio
async def test_missing_newest_soc_value_falls_back_to_older_fresh_sample(
configured_request, monkeypatch, isolated_measurement
):
"""A newest SoC stored as pandas NA is a missing value, not a crash (#1447)."""
_, _, _, data = configured_request
data["soc"] = {}
monkeypatch.setattr(EnergyManagement, "_start_datetime", None)
monkeypatch.setattr(EnergyManagement, "_observation_datetime", None)
ems = EnergyManagement()
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
run_time = to_datetime("2026-09-12T10:26:00Z", in_timezone="UTC")
ems.set_start_datetime(run_time, interval_seconds=900)
monkeypatch.setattr(ConfigOptimizationRequest, "measurement", isolated_measurement)
await isolated_measurement.update_value(run_time.subtract(minutes=2), "storage-soc-factor", 0.42)
await isolated_measurement.update_value(run_time.subtract(minutes=1), "storage-soc-factor", pd.NA)
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 42
2026-11-09 19:11:15 +00:00
@pytest.mark.asyncio
async def test_battery_configuration_error_uses_defaults_and_logs_error(configured_request):
"""A battery that cannot be resolved does not cancel the parameter preparation."""
from loguru import logger
config, _, _, data = configured_request
config.merge_settings_from_dict(
{"devices": {"batteries": {"storage": {"battery_profile": "does-not-exist"}}}}
)
messages: list[str] = []
sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR")
try:
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
finally:
logger.remove(sink_id)
assert parameters.pv_battery is not None
# The packaged generic profile applies with the adjustment.
assert parameters.pv_battery.capacity_wh == 2000
assert parameters.pv_battery.max_charge_power_w == pytest.approx(1000.0)
assert parameters.pv_battery.charge_power_limit_w_by_soc is not None
assert any(
"Battery 'storage'" in m
and "does-not-exist" in m
and "Using defaults for the optimization: battery profile 'generic-lfp'" in m
for m in messages
)
2026-11-10 17:56:13 +00:00
@pytest.mark.asyncio
@pytest.mark.parametrize(
"temperature_degc, expected_max_power_w",
[(None, 1000.0), (45.0, 1000.0), (60.0, 600.0)],
)
async def test_inverter_is_derated_at_measured_temperature(
configured_request, monkeypatch, temperature_degc, expected_max_power_w
):
"""The generic profile derates above 45 degC; without a measurement 45 degC applies."""
from akkudoktoreos.devices.settings import invertermeasurement
_, _, _, data = configured_request
async def measured(key: str, target_datetime: Any) -> Optional[float]:
return temperature_degc if key == "inverter-temperature-degc" else None
monkeypatch.setattr(invertermeasurement, "get_measured_value", measured)
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.inverter is not None
assert parameters.inverter.max_power_wh == pytest.approx(expected_max_power_w)
assert parameters.inverter.max_ac_charge_power_w == pytest.approx(expected_max_power_w)
assert parameters.inverter.ac_to_dc_efficiency_curve is not None
@pytest.mark.asyncio
async def test_unusable_inverter_profile_falls_back_to_generic(configured_request):
"""A missing inverter profile does not cancel the parameter preparation."""
from loguru import logger
config, _, _, data = configured_request
config.merge_settings_from_dict(
{"devices": {"inverters": {"inverter": {"inverter_profile": "does-not-exist"}}}}
)
messages: list[str] = []
sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR")
try:
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
finally:
logger.remove(sink_id)
assert parameters.inverter is not None
assert parameters.inverter.max_power_wh == pytest.approx(1000.0)
assert any(
"Inverter 'inverter'" in m and "inverter profile 'generic-hybrid'" in m
for m in messages
)
@pytest.mark.asyncio
async def test_inverter_without_nominal_power_uses_the_fallback_power(configured_request):
"""The run is not cancelled: the fallback nominal power applies, reported as error."""
from loguru import logger
from akkudoktoreos.devices.settings.inverterresolution import (
INVERTER_FALLBACK_NOMINAL_POWER_W,
)
config, _, _, data = configured_request
config.merge_settings_from_dict(
{"devices": {"inverters": {"inverter": {"profile_adjustment": {"nominal_power_w": None}}}}}
)
messages: list[str] = []
sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR")
try:
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
finally:
logger.remove(sink_id)
assert parameters.inverter is not None
assert parameters.inverter.max_power_wh == pytest.approx(INVERTER_FALLBACK_NOMINAL_POWER_W)
assert any("nominal_power_w" in m for m in messages)