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
+51
View File
@@ -415,3 +415,54 @@ async def test_profile_zero_power_phase_keeps_device_running_until_complete(
if isinstance(item, DDBCInstruction)
]
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