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
@@ -256,3 +256,66 @@ class TestLoadAkkudoktorAdjusted:
|
||||
# Test execution
|
||||
await loadakkudoktoradjusted._update_data()
|
||||
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