mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-29 17:21: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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user