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
+1 -1
View File
@@ -1,6 +1,6 @@
# Akkudoktor-EOS # Akkudoktor-EOS
**Version**: `v0.3.0.dev2610201560628681` **Version**: `v0.3.0.dev2610251221711267`
<!-- pyml disable line-length --> <!-- pyml disable line-length -->
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period. **Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
+1 -1
View File
@@ -8,7 +8,7 @@
"name": "Apache 2.0", "name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html" "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
}, },
"version": "v0.3.0.dev2610201560628681" "version": "v0.3.0.dev2610251221711267"
}, },
"paths": { "paths": {
"/v1/measurement/battery-capacity/{battery_id}": { "/v1/measurement/battery-capacity/{battery_id}": {
+5 -1
View File
@@ -438,6 +438,10 @@ class HomeAssistantAdapter(AdapterProvider):
async def _update_data(self) -> None: async def _update_data(self) -> None:
stage = self.ems.stage() stage = self.ems.stage()
if stage == EnergyManagementStage.DATA_ACQUISITION: if stage == EnergyManagementStage.DATA_ACQUISITION:
# Device states (e.g. SoC) are read now: stamp them with the unrounded run
# time, not the slot start, so freshness checks see their real age (#1447).
# Meter readings stay on the slot start: energy is aggregated per slot.
measured_at = self.ems.observation_datetime
# Sync configuration # Sync configuration
entity_ids = self.config.adapter.homeassistant.config_entity_ids entity_ids = self.config.adapter.homeassistant.config_entity_ids
if entity_ids: if entity_ids:
@@ -468,7 +472,7 @@ class HomeAssistantAdapter(AdapterProvider):
if state: if state:
measurement_value = float(state) measurement_value = float(state)
await self.measurement.update_value( await self.measurement.update_value(
self.ems_start_datetime, measurement_key, measurement_value measured_at, measurement_key, measurement_value
) )
except Exception as e: except Exception as e:
logger.error(f"{e}") logger.error(f"{e}")
@@ -3,6 +3,7 @@
import math import math
from typing import Annotated, Any, Optional from typing import Annotated, Any, Optional
import pandas as pd
from loguru import logger from loguru import logger
from pydantic import AliasChoices, Field, field_validator from pydantic import AliasChoices, Field, field_validator
@@ -104,16 +105,20 @@ class ConfigOptimizationRequest(
end_datetime=observation_time.add(seconds=1), end_datetime=observation_time.add(seconds=1),
dropna=False, dropna=False,
) )
# Records without a SoC value (e.g. a meter reading stored by an
# adapter, or pandas NA) must not hide an older SoC within the age
# limit (#1447). An explicit NaN stays an invalid sample.
samples = [ samples = [
(date, value) (date, value)
for date, value in zip(dates, values) for date, value in zip(dates, values)
if date.timestamp() <= observation_time.timestamp() if date.timestamp() <= observation_time.timestamp()
and value is not None
and value is not pd.NA
] ]
date, value = max(samples, key=lambda sample: sample[0].timestamp()) date, value = max(samples, key=lambda sample: sample[0].timestamp())
if ( if (
observation_time.timestamp() - date.timestamp() observation_time.timestamp() - date.timestamp()
> settings.measurement_max_age_seconds > settings.measurement_max_age_seconds
or value is None
or not math.isfinite(value) or not math.isfinite(value)
or not 0 <= value <= 1 or not 0 <= value <= 1
): ):
@@ -158,17 +163,19 @@ class ConfigOptimizationRequest(
) )
except KeyError: except KeyError:
continue continue
# Skip records without a count (e.g. an adapter's meter reading, #1447).
samples = [ samples = [
(date, count) (date, count)
for date, count in zip(dates, counts) for date, count in zip(dates, counts)
if date.timestamp() <= observation_time.timestamp() if date.timestamp() <= observation_time.timestamp()
and count is not None
and count is not pd.NA
] ]
if not samples: if not samples:
continue continue
count = max(samples, key=lambda item: item[0].timestamp())[1] count = max(samples, key=lambda item: item[0].timestamp())[1]
if ( if (
count is None not math.isfinite(count)
or not math.isfinite(count)
or int(count) != count or int(count) != count
or not 0 <= count <= appliance.num_cycles or not 0 <= count <= appliance.num_cycles
): ):
@@ -11,6 +11,7 @@ forecasts, and fallback defaults, preparing them for optimization runs.
import math import math
from typing import Optional, Union from typing import Optional, Union
import pandas as pd
from loguru import logger from loguru import logger
from pydantic import ( from pydantic import (
AliasChoices, AliasChoices,
@@ -38,7 +39,7 @@ from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters
from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
from akkudoktoreos.utils.datetimeutil import to_duration from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
# Do not import directly from akkudoktoreos.core.coreabc # Do not import directly from akkudoktoreos.core.coreabc
# EnergyManagementSystemMixin - Creates circular dependency with ems.py # EnergyManagementSystemMixin - Creates circular dependency with ems.py
@@ -209,6 +210,30 @@ class Genetic0OptimizationParameters(
raise ValueError("Requires at least two values.") raise ValueError("Requires at least two values.")
return start_solution return start_solution
@classmethod
async def _nearest_soc_factor(cls, key: str, target: DateTime) -> float:
"""SoC factor nearest to the run time within +/-24 h.
Readings carry their real time, not the slot start, and records without a
SoC value (e.g. an adapter's meter reading) are skipped (#1447).
"""
window = to_duration("24 hours")
dates, values = await cls.measurement.key_to_lists(
key=key,
start_datetime=target - window,
end_datetime=target + window,
dropna=False,
)
samples = [
(abs(date.timestamp() - target.timestamp()), value)
for date, value in zip(dates, values)
if value is not None and value is not pd.NA
]
if not samples:
raise ValueError(f"No SoC value for '{key}' within 24 hours of {target}.")
# Convert only the selected sample: an unrelated older value must not fail.
return float(min(samples, key=lambda sample: sample[0])[1])
@classmethod @classmethod
async def prepare(cls) -> "Optional[Genetic0OptimizationParameters]": async def prepare(cls) -> "Optional[Genetic0OptimizationParameters]":
"""Prepare optimization parameters from config, forecast and measurement data. """Prepare optimization parameters from config, forecast and measurement data.
@@ -578,10 +603,8 @@ class Genetic0OptimizationParameters(
battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh
# Initial SOC # Initial SOC
try: try:
initial_soc_factor = await cls.measurement.key_to_value( initial_soc_factor = await cls._nearest_soc_factor(
key=battery_config.measurement_key_soc_factor, battery_config.measurement_key_soc_factor, ems.observation_datetime
target_datetime=ems.start_datetime,
time_window=to_duration(to_duration("48 hours")),
) )
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0: if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error( logger.error(
@@ -643,10 +666,8 @@ class Genetic0OptimizationParameters(
continue continue
# Initial SOC # Initial SOC
try: try:
initial_soc_factor = await cls.measurement.key_to_value( initial_soc_factor = await cls._nearest_soc_factor(
key=electric_vehicle_config.measurement_key_soc_factor, electric_vehicle_config.measurement_key_soc_factor, ems.observation_datetime
target_datetime=ems.start_datetime,
time_window=to_duration(to_duration("48 hours")),
) )
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0: if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error( logger.error(
+10 -3
View File
@@ -120,11 +120,18 @@ class LoadAkkudoktorAdjusted(LoadAkkudoktor):
min_dt = await self.measurement.min_datetime() min_dt = await self.measurement.min_datetime()
# compare predictions with real measurement - try to use last 7 days # compare predictions with real measurement - try to use last 7 days
compare_start = max_dt - to_duration("7 days") # Keep the hourly buckets on full hours: the newest record may be an off-hour
# device state (e.g. SoC stamped with the run time, #1447).
compare_end = max_dt.start_of("hour")
compare_start = compare_end - to_duration("7 days")
if compare_datetimes(compare_start, min_dt).lt: if compare_datetimes(compare_start, min_dt).lt:
# Not enough measurements for 7 days - use what is available # Not enough measurements for 7 days - use what is available
compare_start = min_dt compare_start = min_dt.start_of("hour")
compare_end = max_dt if compare_datetimes(compare_start, min_dt).lt:
compare_start = compare_start.add(hours=1)
if not compare_datetimes(compare_start, compare_end).lt:
# Less than one full hour of measurements - no adjustment yet
return (weekday_adjust, weekend_adjust)
compare_interval = to_duration("1 hour") compare_interval = to_duration("1 hour")
load_total_kwh_array = await self.measurement.load_total_kwh( load_total_kwh_array = await self.measurement.load_total_kwh(
+139
View File
@@ -7,10 +7,12 @@ import httpx
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import pytest import pytest
import pytest_asyncio
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pydantic import ValidationError from pydantic import ValidationError
from akkudoktoreos.config.configmigrate import migrate_config_data 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.ems import EnergyManagement, EnergyManagementStage
from akkudoktoreos.core.emsettings import EnergyManagementMode from akkudoktoreos.core.emsettings import EnergyManagementMode
from akkudoktoreos.optimization.genetic import configrequest from akkudoktoreos.optimization.genetic import configrequest
@@ -22,6 +24,17 @@ from akkudoktoreos.optimization.optimization import (
from akkudoktoreos.utils.datetimeutil import to_datetime 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 @pytest.fixture
def configured_request(config_eos, monkeypatch): def configured_request(config_eos, monkeypatch):
config_eos.merge_settings_from_dict( 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.key_to_lists.assert_not_awaited()
measurement.update_value.assert_not_called() measurement.update_value.assert_not_called()
assert config.model_dump_json() == config_before 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) if isinstance(item, DDBCInstruction)
] ]
assert commands == [(0, 0, "OFF"), (1, 0, "RUN"), (1, 45, "OFF")] 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 # Test execution
await loadakkudoktoradjusted._update_data() await loadakkudoktoradjusted._update_data()
assert mock_adjust.called 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()