Skip to content

Commit e7e5bc2

Browse files
committed
fix: use real elapsed time for energy calculation instead of fixed interval
In WebSocket mode, push updates arrive more frequently than the configured polling interval (e.g. every 15s vs 60s). The energy calculation was using the fixed configured interval for every update, causing energy to be overcounted by a factor of (interval / real_elapsed). Fix: Track the actual timestamp of each update and use the real elapsed time between consecutive updates for the Riemann sum integration. The configured interval is only used as fallback for the very first update after start/restore. Fixes overcounted Energy produced today (DC) sensor values in WebSocket mode.
1 parent 223eae6 commit e7e5bc2

2 files changed

Lines changed: 154 additions & 9 deletions

File tree

custom_components/enpal_webparser/sensor.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ def __init__(self, hass: HomeAssistant, coordinator: DataUpdateCoordinator, sens
261261
self._coordinator = coordinator
262262
self._source_candidates = [make_id(name) for name in sensor_names]
263263
self._active_source_uid = None # Will be determined from available sensors
264-
self._interval_hours = interval_seconds / 3600
264+
self._fallback_interval_hours = interval_seconds / 3600
265+
self._last_update_time = None # Track real elapsed time between updates
265266
self._state = self.hass.data[DOMAIN]["cumulative_energy_state"]
266267
self._value = None
267268
self._last_updated = None
@@ -309,16 +310,32 @@ def _handle_coordinator_update(self):
309310
return
310311

311312
# Now process the update with the active source
313+
now = datetime.now()
312314
for sensor in self._coordinator.data:
313315
if make_id(sensor["name"]) == self._active_source_uid:
314316
try:
315317
power_watt = float(sensor["value"])
316-
energy_kwh = power_watt * self._interval_hours / 1000
318+
319+
# Use actual elapsed time between updates instead of
320+
# the configured interval. In WebSocket mode, push
321+
# updates can arrive much more frequently than the
322+
# polling interval, which would otherwise multiply
323+
# the energy by the wrong factor.
324+
if self._last_update_time is not None:
325+
elapsed_hours = (now - self._last_update_time).total_seconds() / 3600
326+
else:
327+
# First update after start/restore — use the
328+
# configured interval as a reasonable fallback.
329+
elapsed_hours = self._fallback_interval_hours
330+
331+
energy_kwh = power_watt * elapsed_hours / 1000
317332
if self._value is None:
318333
self._value = 0.0
319334
self._value += energy_kwh
320-
self._last_updated = datetime.now().isoformat()
321-
_LOGGER.debug("[Enpal] +%.5f kWh -> Total: %.3f kWh", energy_kwh, self._value)
335+
self._last_update_time = now
336+
self._last_updated = now.isoformat()
337+
_LOGGER.debug("[Enpal] +%.5f kWh (%.1fs elapsed) -> Total: %.3f kWh",
338+
energy_kwh, elapsed_hours * 3600, self._value)
322339
except Exception as e:
323340
_LOGGER.warning("[Enpal] Error in energy calculation: %s", e)
324341
break

custom_components/enpal_webparser/tests/test_sensor_selection.py

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,14 @@ def test_no_matching_sensor_fallback(self, mock_hass, mock_coordinator):
154154
assert "No suitable DC power sensor found" in warning_msg
155155

156156
def test_energy_calculation_with_selected_sensor(self, mock_hass, mock_coordinator):
157-
"""Test that energy calculation works with auto-selected sensor."""
157+
"""Test that energy calculation works with auto-selected sensor.
158+
159+
Uses controlled timestamps to verify the integration:
160+
- First update at t=0: uses fallback interval (3600s = 1h)
161+
- Second update at t=3600s: uses real elapsed time (1h)
162+
"""
163+
from datetime import datetime, timedelta
164+
158165
mock_coordinator.data = [
159166
{"name": "Inverter: Power DC Total (Huawei)", "value": "5000", "timestamp": "01/01/2024 12:00:00"},
160167
]
@@ -164,18 +171,26 @@ def test_energy_calculation_with_selected_sensor(self, mock_hass, mock_coordinat
164171
)
165172
sensor._value = 0.0
166173

167-
# First update - selects sensor and calculates
168-
sensor._handle_coordinator_update()
174+
# First update at t=0 — no previous timestamp, uses fallback interval (1h)
175+
t0 = datetime(2024, 1, 1, 12, 0, 0)
176+
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
177+
mock_dt.now.return_value = t0
178+
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
179+
sensor._handle_coordinator_update()
169180

170181
# Should have calculated energy: 5000W * 1h / 1000 = 5 kWh
171182
assert sensor._active_source_uid == "inverter_power_dc_total_huawei"
172183
assert sensor._value == pytest.approx(5.0, rel=0.01)
173184

174-
# Second update with different power
185+
# Second update 1 hour later with different power
175186
mock_coordinator.data = [
176187
{"name": "Inverter: Power DC Total (Huawei)", "value": "3000", "timestamp": "01/01/2024 13:00:00"},
177188
]
178-
sensor._handle_coordinator_update()
189+
t1 = t0 + timedelta(hours=1)
190+
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
191+
mock_dt.now.return_value = t1
192+
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
193+
sensor._handle_coordinator_update()
179194

180195
# Should add: 5 + 3 = 8 kWh
181196
assert sensor._value == pytest.approx(8.0, rel=0.01)
@@ -318,6 +333,119 @@ def test_make_id_consistency(self, mock_hass, mock_coordinator):
318333
assert make_id("Inverter: Power DC Total Calculated") == "inverter_power_dc_total_calculated"
319334
assert make_id("Inverter: Power DC Total") == "inverter_power_dc_total"
320335
assert make_id("Inverter: Power DC Total SMA") == "inverter_power_dc_total_sma"
336+
337+
338+
class TestCumulativeEnergySensorElapsedTime:
339+
"""Test that the energy calculation uses real elapsed time, not the configured interval.
340+
341+
In WebSocket mode, updates arrive more frequently (e.g. every 15s) than the
342+
configured polling interval (e.g. 60s). Using the configured interval would
343+
over-count energy by a factor of (interval / real_elapsed).
344+
"""
345+
346+
def test_first_update_uses_fallback_interval(self, mock_hass, mock_coordinator):
347+
"""On the very first update, use the configured interval as fallback."""
348+
mock_coordinator.data = [
349+
{"name": "Inverter: Power DC Total (Huawei)", "value": "6000", "timestamp": "01/01/2024 12:00:00"},
350+
]
351+
sensor = create_sensor_with_mocked_state(
352+
mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60
353+
)
354+
sensor._value = 0.0
355+
356+
sensor._handle_coordinator_update()
357+
358+
# 6000 W * (60s / 3600) / 1000 = 0.1 kWh
359+
assert sensor._value == pytest.approx(0.1, abs=1e-6)
360+
361+
def test_elapsed_time_used_for_subsequent_updates(self, mock_hass, mock_coordinator):
362+
"""After the first update, real elapsed time must be used."""
363+
from datetime import datetime, timedelta
364+
365+
mock_coordinator.data = [
366+
{"name": "Inverter: Power DC Total (Huawei)", "value": "6000", "timestamp": "01/01/2024 12:00:00"},
367+
]
368+
sensor = create_sensor_with_mocked_state(
369+
mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60
370+
)
371+
sensor._value = 0.0
372+
373+
# Simulate first update at a known time
374+
t0 = datetime(2024, 6, 1, 12, 0, 0)
375+
sensor._last_update_time = t0
376+
sensor._active_source_uid = "inverter_power_dc_total_huawei"
377+
378+
# Simulate second update 15 seconds later (WebSocket push)
379+
t1 = t0 + timedelta(seconds=15)
380+
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
381+
mock_dt.now.return_value = t1
382+
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
383+
sensor._handle_coordinator_update()
384+
385+
# 6000 W * (15s / 3600) / 1000 = 0.025 kWh
386+
assert sensor._value == pytest.approx(0.025, abs=1e-6)
387+
388+
def test_rapid_updates_do_not_overcount(self, mock_hass, mock_coordinator):
389+
"""Simulate 4 rapid updates at 15s intervals — total must match 1 minute of production."""
390+
from datetime import datetime, timedelta
391+
392+
mock_coordinator.data = [
393+
{"name": "Inverter: Power DC Total (Huawei)", "value": "3600", "timestamp": "01/01/2024 12:00:00"},
394+
]
395+
sensor = create_sensor_with_mocked_state(
396+
mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60
397+
)
398+
sensor._value = 0.0
399+
sensor._active_source_uid = "inverter_power_dc_total_huawei"
400+
401+
base_time = datetime(2024, 6, 1, 12, 0, 0)
402+
sensor._last_update_time = base_time
403+
404+
# 4 updates, each 15 seconds apart — totalling 60 seconds
405+
for i in range(1, 5):
406+
t = base_time + timedelta(seconds=15 * i)
407+
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
408+
mock_dt.now.return_value = t
409+
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
410+
sensor._handle_coordinator_update()
411+
412+
# 3600 W * (60s total / 3600) / 1000 = 0.06 kWh
413+
# This is the same result as a single 60s poll — no overcounting.
414+
assert sensor._value == pytest.approx(0.06, abs=1e-6)
415+
416+
def test_old_interval_would_overcount(self, mock_hass, mock_coordinator):
417+
"""Demonstrate that using the fixed interval in the same scenario would overcount.
418+
419+
With 4 updates at 15s intervals but interval=60s the old code would have
420+
calculated 4 * (3600 * 60/3600 / 1000) = 4 * 0.06 = 0.24 kWh instead of
421+
the correct 0.06 kWh — a 4× overcounting.
422+
423+
This test verifies the fix by asserting the correct value (0.06 kWh).
424+
"""
425+
from datetime import datetime, timedelta
426+
427+
mock_coordinator.data = [
428+
{"name": "Inverter: Power DC Total (Huawei)", "value": "3600", "timestamp": "01/01/2024 12:00:00"},
429+
]
430+
sensor = create_sensor_with_mocked_state(
431+
mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60
432+
)
433+
sensor._value = 0.0
434+
sensor._active_source_uid = "inverter_power_dc_total_huawei"
435+
436+
base_time = datetime(2024, 6, 1, 12, 0, 0)
437+
sensor._last_update_time = base_time
438+
439+
for i in range(1, 5):
440+
t = base_time + timedelta(seconds=15 * i)
441+
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
442+
mock_dt.now.return_value = t
443+
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
444+
sensor._handle_coordinator_update()
445+
446+
# Must NOT be 0.24 (the old bug) — must be 0.06
447+
assert sensor._value != pytest.approx(0.24, abs=0.01)
448+
assert sensor._value == pytest.approx(0.06, abs=1e-6)
321449

322450
# Verify sensor uses same transformation
323451
mock_coordinator.data = [

0 commit comments

Comments
 (0)