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
+5 -1
View File
@@ -438,6 +438,10 @@ class HomeAssistantAdapter(AdapterProvider):
async def _update_data(self) -> None:
stage = self.ems.stage()
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
entity_ids = self.config.adapter.homeassistant.config_entity_ids
if entity_ids:
@@ -468,7 +472,7 @@ class HomeAssistantAdapter(AdapterProvider):
if state:
measurement_value = float(state)
await self.measurement.update_value(
self.ems_start_datetime, measurement_key, measurement_value
measured_at, measurement_key, measurement_value
)
except Exception as e:
logger.error(f"{e}")
@@ -3,6 +3,7 @@
import math
from typing import Annotated, Any, Optional
import pandas as pd
from loguru import logger
from pydantic import AliasChoices, Field, field_validator
@@ -104,16 +105,20 @@ class ConfigOptimizationRequest(
end_datetime=observation_time.add(seconds=1),
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 = [
(date, value)
for date, value in zip(dates, values)
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())
if (
observation_time.timestamp() - date.timestamp()
> settings.measurement_max_age_seconds
or value is None
or not math.isfinite(value)
or not 0 <= value <= 1
):
@@ -158,17 +163,19 @@ class ConfigOptimizationRequest(
)
except KeyError:
continue
# Skip records without a count (e.g. an adapter's meter reading, #1447).
samples = [
(date, count)
for date, count in zip(dates, counts)
if date.timestamp() <= observation_time.timestamp()
and count is not None
and count is not pd.NA
]
if not samples:
continue
count = max(samples, key=lambda item: item[0].timestamp())[1]
if (
count is None
or not math.isfinite(count)
not math.isfinite(count)
or int(count) != count
or not 0 <= count <= appliance.num_cycles
):
@@ -11,6 +11,7 @@ forecasts, and fallback defaults, preparing them for optimization runs.
import math
from typing import Optional, Union
import pandas as pd
from loguru import logger
from pydantic import (
AliasChoices,
@@ -38,7 +39,7 @@ from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters
from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel
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
# EnergyManagementSystemMixin - Creates circular dependency with ems.py
@@ -209,6 +210,30 @@ class Genetic0OptimizationParameters(
raise ValueError("Requires at least two values.")
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
async def prepare(cls) -> "Optional[Genetic0OptimizationParameters]":
"""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
# Initial SOC
try:
initial_soc_factor = await cls.measurement.key_to_value(
key=battery_config.measurement_key_soc_factor,
target_datetime=ems.start_datetime,
time_window=to_duration(to_duration("48 hours")),
initial_soc_factor = await cls._nearest_soc_factor(
battery_config.measurement_key_soc_factor, ems.observation_datetime
)
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error(
@@ -643,10 +666,8 @@ class Genetic0OptimizationParameters(
continue
# Initial SOC
try:
initial_soc_factor = await cls.measurement.key_to_value(
key=electric_vehicle_config.measurement_key_soc_factor,
target_datetime=ems.start_datetime,
time_window=to_duration(to_duration("48 hours")),
initial_soc_factor = await cls._nearest_soc_factor(
electric_vehicle_config.measurement_key_soc_factor, ems.observation_datetime
)
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error(
+10 -3
View File
@@ -120,11 +120,18 @@ class LoadAkkudoktorAdjusted(LoadAkkudoktor):
min_dt = await self.measurement.min_datetime()
# 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:
# Not enough measurements for 7 days - use what is available
compare_start = min_dt
compare_end = max_dt
compare_start = min_dt.start_of("hour")
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")
load_total_kwh_array = await self.measurement.load_total_kwh(