mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-29 09:11:57 +00:00
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:
co-authored by
Claude Opus 5.5
parent
5249b389f5
commit
58b968562e
@@ -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
@@ -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}": {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user