From f4350808ae383470ab72d409c0d1ce6e0bad1854 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 6 Sep 2026 00:36:06 -0700 Subject: [PATCH] Use Opower's import/export registers for grid statistics (#181286) --- .../components/opower/coordinator.py | 13 +- tests/components/opower/test_coordinator.py | 128 ++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 670edce22dbb..96da7fe03881 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -332,8 +332,17 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): cost_state = max(0, cost_read.provided_cost) compensation_state = max(0, -cost_read.provided_cost) - consumption_state = max(0, cost_read.consumption) - return_state = max(0, -cost_read.consumption) + # Prefer the meter's own import and export registers when the + # utility publishes them. Splitting the net consumption on its + # sign undercounts both directions on a net-metered site, + # because an interval that is net-export can still contain real + # import (and vice versa). + if cost_read.imported is not None and cost_read.exported is not None: + consumption_state = cost_read.imported + return_state = cost_read.exported + else: + consumption_state = max(0, cost_read.consumption) + return_state = max(0, -cost_read.consumption) cost_sum += cost_state compensation_sum += compensation_state diff --git a/tests/components/opower/test_coordinator.py b/tests/components/opower/test_coordinator.py index 03abc408850e..7e33fe606ce9 100644 --- a/tests/components/opower/test_coordinator.py +++ b/tests/components/opower/test_coordinator.py @@ -142,6 +142,134 @@ async def test_coordinator_subsequent_run( assert stats == snapshot +async def test_coordinator_uses_import_export_registers( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test the meter's own import/export registers are preferred over the net. + + An interval that is net-export can still contain real grid import, so + splitting the net on its sign undercounts both directions. When the utility + publishes the registers we use them verbatim. + """ + mock_opower_api.async_get_cost_reads.return_value = [ + # Net export, but 0.25 kWh really was taken from the grid in that hour. + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=-3.9, + provided_cost=-1.2, + imported=0.25, + exported=4.15, + ), + # Net import, with a little export in the same hour. + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + consumption=1.5, + provided_cost=0.5, + imported=1.75, + exported=0.25, + ), + ] + + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "opower:pge_elec_111111_energy_consumption", + "opower:pge_elec_111111_energy_return", + }, + "hour", + None, + {"state", "sum"}, + ) + + consumption = stats["opower:pge_elec_111111_energy_consumption"] + grid_return = stats["opower:pge_elec_111111_energy_return"] + # Sign splitting would have recorded 0 and 1.5 for consumption + # and 3.9 and 0 for return. + assert [s["state"] for s in consumption] == [0.25, 1.75] + assert [s["sum"] for s in consumption] == [0.25, 2.0] + assert [s["state"] for s in grid_return] == [4.15, 0.25] + assert [s["sum"] for s in grid_return] == [4.15, 4.4] + + +async def test_coordinator_falls_back_to_sign_split( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test reads without registers still split the net on its sign. + + Utilities that do not publish the registers leave both fields None, and even + those that do can leave gaps (PG&E publishes none for the hours around a DST + transition), so both paths have to coexist within a single update. + """ + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=-3.9, + provided_cost=-1.2, + ), + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + consumption=1.5, + provided_cost=0.5, + imported=1.75, + exported=0.25, + ), + # Only one of the two registers came back; not enough to use either. + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 11)), + consumption=2.0, + provided_cost=0.7, + imported=2.0, + ), + ] + + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "opower:pge_elec_111111_energy_consumption", + "opower:pge_elec_111111_energy_return", + }, + "hour", + None, + {"state", "sum"}, + ) + + assert [s["state"] for s in stats["opower:pge_elec_111111_energy_consumption"]] == [ + 0.0, + 1.75, + 2.0, + ] + assert [s["state"] for s in stats["opower:pge_elec_111111_energy_return"]] == [ + 3.9, + 0.25, + 0.0, + ] + + async def test_coordinator_subsequent_run_no_energy_data( recorder_mock: Recorder, hass: HomeAssistant,