From e7e5bc2efd8185c6b88e90e616d116db4cb78cd9 Mon Sep 17 00:00:00 2001 From: Oliver Stock Date: Sun, 15 Mar 2026 17:07:55 +0100 Subject: [PATCH] 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. --- custom_components/enpal_webparser/sensor.py | 25 +++- .../tests/test_sensor_selection.py | 138 +++++++++++++++++- 2 files changed, 154 insertions(+), 9 deletions(-) diff --git a/custom_components/enpal_webparser/sensor.py b/custom_components/enpal_webparser/sensor.py index 4dbcbb6..62bb93b 100644 --- a/custom_components/enpal_webparser/sensor.py +++ b/custom_components/enpal_webparser/sensor.py @@ -261,7 +261,8 @@ def __init__(self, hass: HomeAssistant, coordinator: DataUpdateCoordinator, sens self._coordinator = coordinator self._source_candidates = [make_id(name) for name in sensor_names] self._active_source_uid = None # Will be determined from available sensors - self._interval_hours = interval_seconds / 3600 + self._fallback_interval_hours = interval_seconds / 3600 + self._last_update_time = None # Track real elapsed time between updates self._state = self.hass.data[DOMAIN]["cumulative_energy_state"] self._value = None self._last_updated = None @@ -309,16 +310,32 @@ def _handle_coordinator_update(self): return # Now process the update with the active source + now = datetime.now() for sensor in self._coordinator.data: if make_id(sensor["name"]) == self._active_source_uid: try: power_watt = float(sensor["value"]) - energy_kwh = power_watt * self._interval_hours / 1000 + + # Use actual elapsed time between updates instead of + # the configured interval. In WebSocket mode, push + # updates can arrive much more frequently than the + # polling interval, which would otherwise multiply + # the energy by the wrong factor. + if self._last_update_time is not None: + elapsed_hours = (now - self._last_update_time).total_seconds() / 3600 + else: + # First update after start/restore — use the + # configured interval as a reasonable fallback. + elapsed_hours = self._fallback_interval_hours + + energy_kwh = power_watt * elapsed_hours / 1000 if self._value is None: self._value = 0.0 self._value += energy_kwh - self._last_updated = datetime.now().isoformat() - _LOGGER.debug("[Enpal] +%.5f kWh -> Total: %.3f kWh", energy_kwh, self._value) + self._last_update_time = now + self._last_updated = now.isoformat() + _LOGGER.debug("[Enpal] +%.5f kWh (%.1fs elapsed) -> Total: %.3f kWh", + energy_kwh, elapsed_hours * 3600, self._value) except Exception as e: _LOGGER.warning("[Enpal] Error in energy calculation: %s", e) break diff --git a/custom_components/enpal_webparser/tests/test_sensor_selection.py b/custom_components/enpal_webparser/tests/test_sensor_selection.py index dceb172..25ac6d3 100644 --- a/custom_components/enpal_webparser/tests/test_sensor_selection.py +++ b/custom_components/enpal_webparser/tests/test_sensor_selection.py @@ -154,7 +154,14 @@ def test_no_matching_sensor_fallback(self, mock_hass, mock_coordinator): assert "No suitable DC power sensor found" in warning_msg def test_energy_calculation_with_selected_sensor(self, mock_hass, mock_coordinator): - """Test that energy calculation works with auto-selected sensor.""" + """Test that energy calculation works with auto-selected sensor. + + Uses controlled timestamps to verify the integration: + - First update at t=0: uses fallback interval (3600s = 1h) + - Second update at t=3600s: uses real elapsed time (1h) + """ + from datetime import datetime, timedelta + mock_coordinator.data = [ {"name": "Inverter: Power DC Total (Huawei)", "value": "5000", "timestamp": "01/01/2024 12:00:00"}, ] @@ -164,18 +171,26 @@ def test_energy_calculation_with_selected_sensor(self, mock_hass, mock_coordinat ) sensor._value = 0.0 - # First update - selects sensor and calculates - sensor._handle_coordinator_update() + # First update at t=0 — no previous timestamp, uses fallback interval (1h) + t0 = datetime(2024, 1, 1, 12, 0, 0) + with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt: + mock_dt.now.return_value = t0 + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + sensor._handle_coordinator_update() # Should have calculated energy: 5000W * 1h / 1000 = 5 kWh assert sensor._active_source_uid == "inverter_power_dc_total_huawei" assert sensor._value == pytest.approx(5.0, rel=0.01) - # Second update with different power + # Second update 1 hour later with different power mock_coordinator.data = [ {"name": "Inverter: Power DC Total (Huawei)", "value": "3000", "timestamp": "01/01/2024 13:00:00"}, ] - sensor._handle_coordinator_update() + t1 = t0 + timedelta(hours=1) + with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt: + mock_dt.now.return_value = t1 + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + sensor._handle_coordinator_update() # Should add: 5 + 3 = 8 kWh assert sensor._value == pytest.approx(8.0, rel=0.01) @@ -318,6 +333,119 @@ def test_make_id_consistency(self, mock_hass, mock_coordinator): assert make_id("Inverter: Power DC Total Calculated") == "inverter_power_dc_total_calculated" assert make_id("Inverter: Power DC Total") == "inverter_power_dc_total" assert make_id("Inverter: Power DC Total SMA") == "inverter_power_dc_total_sma" + + +class TestCumulativeEnergySensorElapsedTime: + """Test that the energy calculation uses real elapsed time, not the configured interval. + + In WebSocket mode, updates arrive more frequently (e.g. every 15s) than the + configured polling interval (e.g. 60s). Using the configured interval would + over-count energy by a factor of (interval / real_elapsed). + """ + + def test_first_update_uses_fallback_interval(self, mock_hass, mock_coordinator): + """On the very first update, use the configured interval as fallback.""" + mock_coordinator.data = [ + {"name": "Inverter: Power DC Total (Huawei)", "value": "6000", "timestamp": "01/01/2024 12:00:00"}, + ] + sensor = create_sensor_with_mocked_state( + mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60 + ) + sensor._value = 0.0 + + sensor._handle_coordinator_update() + + # 6000 W * (60s / 3600) / 1000 = 0.1 kWh + assert sensor._value == pytest.approx(0.1, abs=1e-6) + + def test_elapsed_time_used_for_subsequent_updates(self, mock_hass, mock_coordinator): + """After the first update, real elapsed time must be used.""" + from datetime import datetime, timedelta + + mock_coordinator.data = [ + {"name": "Inverter: Power DC Total (Huawei)", "value": "6000", "timestamp": "01/01/2024 12:00:00"}, + ] + sensor = create_sensor_with_mocked_state( + mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60 + ) + sensor._value = 0.0 + + # Simulate first update at a known time + t0 = datetime(2024, 6, 1, 12, 0, 0) + sensor._last_update_time = t0 + sensor._active_source_uid = "inverter_power_dc_total_huawei" + + # Simulate second update 15 seconds later (WebSocket push) + t1 = t0 + timedelta(seconds=15) + with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt: + mock_dt.now.return_value = t1 + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + sensor._handle_coordinator_update() + + # 6000 W * (15s / 3600) / 1000 = 0.025 kWh + assert sensor._value == pytest.approx(0.025, abs=1e-6) + + def test_rapid_updates_do_not_overcount(self, mock_hass, mock_coordinator): + """Simulate 4 rapid updates at 15s intervals — total must match 1 minute of production.""" + from datetime import datetime, timedelta + + mock_coordinator.data = [ + {"name": "Inverter: Power DC Total (Huawei)", "value": "3600", "timestamp": "01/01/2024 12:00:00"}, + ] + sensor = create_sensor_with_mocked_state( + mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60 + ) + sensor._value = 0.0 + sensor._active_source_uid = "inverter_power_dc_total_huawei" + + base_time = datetime(2024, 6, 1, 12, 0, 0) + sensor._last_update_time = base_time + + # 4 updates, each 15 seconds apart — totalling 60 seconds + for i in range(1, 5): + t = base_time + timedelta(seconds=15 * i) + with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt: + mock_dt.now.return_value = t + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + sensor._handle_coordinator_update() + + # 3600 W * (60s total / 3600) / 1000 = 0.06 kWh + # This is the same result as a single 60s poll — no overcounting. + assert sensor._value == pytest.approx(0.06, abs=1e-6) + + def test_old_interval_would_overcount(self, mock_hass, mock_coordinator): + """Demonstrate that using the fixed interval in the same scenario would overcount. + + With 4 updates at 15s intervals but interval=60s the old code would have + calculated 4 * (3600 * 60/3600 / 1000) = 4 * 0.06 = 0.24 kWh instead of + the correct 0.06 kWh — a 4× overcounting. + + This test verifies the fix by asserting the correct value (0.06 kWh). + """ + from datetime import datetime, timedelta + + mock_coordinator.data = [ + {"name": "Inverter: Power DC Total (Huawei)", "value": "3600", "timestamp": "01/01/2024 12:00:00"}, + ] + sensor = create_sensor_with_mocked_state( + mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60 + ) + sensor._value = 0.0 + sensor._active_source_uid = "inverter_power_dc_total_huawei" + + base_time = datetime(2024, 6, 1, 12, 0, 0) + sensor._last_update_time = base_time + + for i in range(1, 5): + t = base_time + timedelta(seconds=15 * i) + with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt: + mock_dt.now.return_value = t + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + sensor._handle_coordinator_update() + + # Must NOT be 0.24 (the old bug) — must be 0.06 + assert sensor._value != pytest.approx(0.24, abs=0.01) + assert sensor._value == pytest.approx(0.06, abs=1e-6) # Verify sensor uses same transformation mock_coordinator.data = [