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
+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(