fix(adapter): stamp Home Assistant device states with run time, not slot start (#1452)

* fix(adapter): stamp Home Assistant device states with run time, not slot start

The Home Assistant adapter stored every reading with ems_start_datetime,
which set_start_datetime() rounds down to the optimization slot (e.g.
14:00 for a run at 14:11). The GENETIC configuration request checks SoC
freshness against the unrounded observation_datetime with
measurement_max_age_seconds (default 300 s). Any run starting more than
five minutes into a slot therefore discarded the freshly read SoC and
optimized with 0 % initial SoC.

Stamp device state measurements (device_measurement_entity_ids, e.g.
SoC) with observation_datetime instead. Not to_datetime(): a reading
taken a few seconds after the observation time would be rejected as a
future sample. Meter readings (load, grid import/export, PV) keep the
slot start, because their energy is aggregated per slot.

With device states at their real time, a record holding only some keys
can now be the newest one (e.g. the Home Assistant battery SoC while the
EV SoC or a cycle count comes through the measurement API). Readers
therefore skip records without the value they need:

- GENETIC SoC and completed appliance cycles: such a record no longer
  hides a fresh value (the cycle count case aborted the optimization).
  This also covers a meter reading at the slot start that is newer than
  a SoC supplied through the measurement API. Values stored as pandas NA
  count as missing instead of raising TypeError; an explicit NaN stays
  an invalid sample.
- GENETIC0 SoC: looks up the reading nearest to observation_datetime
  instead of the slot start, skipping records without SoC and converting
  only the selected sample. Before, a run late in the slot (14:40 in the
  14:00 slot) would pick the previous run's reading (13:40). This also
  fixes SoC values PUT via the REST API with their real time.

LoadAkkudoktorAdjusted anchored its hourly comparison window on the
newest record of any key. An off-hour device state would shift every
bucket (e.g. to :11) against hour-aligned meter readings; the window now
starts and ends on full hours, and less than one full hour of data
yields no adjustment instead of an error. (15-minute slot starts and SoC values
PUT with their real time had the same effect before.)

Regenerated openapi.json and docs/_generated/openapi.md (version only).

Fixes #1447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYWR1HVM8a8src23xuyL3T

* test(genetic0): use pv_battery instead of deprecated pv_akku

mypy flags attribute access on the pv_akku computed field
(attr-defined), which failed the pre-commit check of the PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYWR1HVM8a8src23xuyL3T

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Christin
2026-10-26 07:14:00 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5249b389f5
commit 58b968562e
10 changed files with 470 additions and 18 deletions
+139
View File
@@ -7,10 +7,12 @@ 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
@@ -22,6 +24,17 @@ from akkudoktoreos.optimization.optimization import (
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(
@@ -514,3 +527,129 @@ async def test_explicit_ev_soc_has_no_measurement_age_limit(configured_request,
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
+160
View File
@@ -0,0 +1,160 @@
"""GENETIC0 must use the SoC read during the run, not an older reading (#1447)."""
from unittest.mock import AsyncMock, Mock, patch
import numpy as np
import pandas as pd
import pytest
import pytest_asyncio
from akkudoktoreos.core.coreabc import get_ems, get_measurement
from akkudoktoreos.optimization.genetic0.genetic0params import (
Genetic0OptimizationParameters,
)
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.mark.asyncio
async def test_soc_read_late_in_slot_beats_previous_reading(config_eos, isolated_measurement):
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"genetic0": {"horizon_hours": 24}},
"elecfee": {"provider": None},
"devices": {
"max_batteries": 1,
"max_electric_vehicles": 0,
"max_inverters": 1,
"max_home_appliances": 0,
"batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
},
}
)
# A run at 14:40 belongs to the 14:00 slot. The previous run (13:40) is closer
# to the slot start than the reading taken now.
run_time = to_datetime("2026-08-01T14:40:00+00:00")
get_ems().set_start_datetime(run_time)
measurement = isolated_measurement
await measurement.update_value(run_time.subtract(hours=1), "storage-soc-factor", 0.10)
await measurement.update_value(run_time, "storage-soc-factor", 0.42)
tariff = pd.Series(
0.00008, index=pd.date_range("2026-08-01T00:00:00Z", periods=72 * 4, freq="15min")
)
async def read_array(**kwargs):
return np.full(48, 0.0003)
prediction = Mock(
update_data=AsyncMock(),
key_to_array=AsyncMock(side_effect=read_array),
key_to_raw_series=AsyncMock(return_value=tariff),
)
with patch.object(Genetic0OptimizationParameters, "prediction", prediction):
parameters = await Genetic0OptimizationParameters.prepare()
assert parameters is not None
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 42
@pytest.mark.asyncio
async def test_meter_only_record_does_not_mask_soc(config_eos, isolated_measurement):
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"genetic0": {"horizon_hours": 24}},
"elecfee": {"provider": None},
"measurement": {"load_emr_keys": ["load_meter"]},
"devices": {
"max_batteries": 1,
"max_electric_vehicles": 0,
"max_inverters": 1,
"max_home_appliances": 0,
"batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
},
}
)
run_time = to_datetime("2026-08-01T14:40:00+00:00")
get_ems().set_start_datetime(run_time)
measurement = isolated_measurement
# SoC through the measurement API at 13:30, the adapter's meter reading at the
# slot start (14:00); the run at 14:40 must still find the SoC.
await measurement.update_value(run_time.subtract(minutes=70), "storage-soc-factor", 0.42)
await measurement.update_value(get_ems().start_datetime, "load_meter", 1234.5)
tariff = pd.Series(
0.00008, index=pd.date_range("2026-08-01T00:00:00Z", periods=72 * 4, freq="15min")
)
async def read_array(**kwargs):
return np.full(48, 0.0003)
prediction = Mock(
update_data=AsyncMock(),
key_to_array=AsyncMock(side_effect=read_array),
key_to_raw_series=AsyncMock(return_value=tariff),
)
with patch.object(Genetic0OptimizationParameters, "prediction", prediction):
parameters = await Genetic0OptimizationParameters.prepare()
assert parameters is not None
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 42
@pytest.mark.asyncio
async def test_older_missing_value_does_not_spoil_current_soc(config_eos, isolated_measurement):
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"genetic0": {"horizon_hours": 24}},
"elecfee": {"provider": None},
"devices": {
"max_batteries": 1,
"max_electric_vehicles": 0,
"max_inverters": 1,
"max_home_appliances": 0,
"batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
},
}
)
run_time = to_datetime("2026-08-01T14:40:00+00:00")
get_ems().set_start_datetime(run_time)
measurement = isolated_measurement
# An older reading stored as pandas NA (e.g. a JSON null in a Float64 series).
await measurement.update_value(run_time.subtract(hours=1), "storage-soc-factor", pd.NA)
await measurement.update_value(run_time, "storage-soc-factor", 0.42)
tariff = pd.Series(
0.00008, index=pd.date_range("2026-08-01T00:00:00Z", periods=72 * 4, freq="15min")
)
async def read_array(**kwargs):
return np.full(48, 0.0003)
prediction = Mock(
update_data=AsyncMock(),
key_to_array=AsyncMock(side_effect=read_array),
key_to_raw_series=AsyncMock(return_value=tariff),
)
with patch.object(Genetic0OptimizationParameters, "prediction", prediction):
parameters = await Genetic0OptimizationParameters.prepare()
assert parameters is not None
assert parameters.pv_battery is not None
assert parameters.pv_battery.initial_soc_percentage == 42
+51
View File
@@ -415,3 +415,54 @@ async def test_profile_zero_power_phase_keeps_device_running_until_complete(
if isinstance(item, DDBCInstruction)
]
assert commands == [(0, 0, "OFF"), (1, 0, "RUN"), (1, 45, "OFF")]
@pytest.mark.asyncio
async def test_meter_only_record_does_not_hide_completed_cycles(
config_eos: ConfigEOS, isolated_measurement: Measurement
) -> None:
"""A newer record without a cycle count (meter reading) must not abort the run (#1447)."""
configure(config_eos)
config_eos.merge_settings_from_dict(
{
"measurement": {"load_emr_keys": ["load_meter"]},
"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,
cycle_time_windows={
"windows": [
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
]
},
)
},
},
}
)
run_time = get_ems().start_datetime.add(minutes=26)
get_ems().set_start_datetime(run_time, interval_seconds=900)
measurement = isolated_measurement
# Cycle count through the measurement API, then the adapter's meter reading.
await measurement.update_value(run_time.subtract(seconds=30), "washer.cycles_completed", 1.0)
await measurement.update_value(run_time, "load_meter", 1234.5)
prepared = await ConfigOptimizationRequest.model_validate(
{
"forecasts": {
"pv_forecast_wh": [0.0] * 20,
"total_load": [0.0] * 20,
"electricity_price_per_wh": [0.0003] * 20,
"feed_in_tariff_per_wh": [0.0001] * 20,
}
}
).resolve()
assert prepared.home_appliances is not None
assert prepared.home_appliances[0].completed_cycles == 1
+63
View File
@@ -256,3 +256,66 @@ class TestLoadAkkudoktorAdjusted:
# Test execution
await loadakkudoktoradjusted._update_data()
assert mock_adjust.called
@pytest_asyncio.fixture
async def empty_measurement(config_eos):
"""Own, empty measurement store (the singleton is shared between tests)."""
measurement = get_measurement()
await measurement.delete_by_datetime(None, None)
config_eos.merge_settings_from_dict(
{"devices": {"max_batteries": 1, "batteries": {"storage": {"capacity_wh": 2000}}}}
)
try:
yield measurement
finally:
await measurement.delete_by_datetime(None, None)
async def _adjustment_window(adjusted, measurement):
measurement_class = type(measurement)
original = measurement_class.load_total_kwh
calls = []
async def spy(self, *args, **kwargs):
calls.append(kwargs)
return await original(self, *args, **kwargs)
with patch.object(measurement_class, "load_total_kwh", spy):
result = await adjusted._calculate_adjustment(np.random.rand(365, 2, 24))
return result, calls
@pytest.mark.asyncio
async def test_adjustment_buckets_stay_on_full_hours(loadakkudoktoradjusted, empty_measurement):
"""A newer off-hour record (e.g. a device SoC) must not shift the hourly buckets (#1447)."""
dt = to_datetime("2024-01-01T00:00:00")
for hour in range(25):
await empty_measurement.update_value(dt.add(hours=hour), "load0_mr", 500.0 + hour)
await empty_measurement.update_value(
to_datetime("2024-01-02T00:11:00"), "storage-soc-factor", 0.42
)
_, calls = await _adjustment_window(loadakkudoktoradjusted, empty_measurement)
assert calls
assert calls[0]["start_datetime"] == to_datetime("2024-01-01T00:00:00")
assert calls[0]["end_datetime"] == to_datetime("2024-01-02T00:00:00")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"records",
[
[("14:15", "load0_mr", 1000.0), ("14:26", "storage-soc-factor", 0.42)],
[("14:11", "storage-soc-factor", 0.42)],
],
)
async def test_adjustment_within_first_hour_returns_zero(
loadakkudoktoradjusted, empty_measurement, records
):
"""Less than one full hour of data yields no adjustment instead of an error."""
for time, key, value in records:
await empty_measurement.update_value(to_datetime(f"2026-08-01T{time}:00"), key, value)
(weekday, weekend), calls = await _adjustment_window(loadakkudoktoradjusted, empty_measurement)
assert calls == []
assert not weekday.any() and not weekend.any()