From 45fcaadbf5abbcdc3b3996134a6cf7bbe94588bf Mon Sep 17 00:00:00 2001 From: bvweerd <5443524+bvweerd@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:45:35 +0000 Subject: [PATCH] fix: price-change fallback trigger no longer goes dead at a zero price The fallback threshold check in _handle_price_change skipped entirely when the previous price was exactly 0 (a free hour): the relative change is undefined there. Worse, _last_price was never updated inside the skipped branch, so once a 0 was stored the fallback path stayed disabled until a period boundary. A zero previous price now uses an absolute threshold (PRICE_CHANGE_REOPTIMIZE_ABS_EUR, 0.01 EUR/kWh) and _last_price is always updated. https://claude.ai/code/session_01ViddzhUiQT1rMibFjc2pn6 --- custom_components/battery_controller/const.py | 3 + .../coordinator_optimization.py | 22 +++-- tests/test_coordinator_optimization.py | 80 +++++++++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/custom_components/battery_controller/const.py b/custom_components/battery_controller/const.py index f7770a0..911d57a 100644 --- a/custom_components/battery_controller/const.py +++ b/custom_components/battery_controller/const.py @@ -174,6 +174,9 @@ PRICE_CHANGE_REOPTIMIZE_THRESHOLD = ( 0.10 # fractional — re-run optimizer on >=10% price change ) +PRICE_CHANGE_REOPTIMIZE_ABS_EUR = ( + 0.01 # EUR/kWh — absolute change threshold when the previous price is 0 +) STALE_SENSOR_MULTIPLIER = ( 2.0 # x response_time_s — age limit before sensor is treated as stale ) diff --git a/custom_components/battery_controller/coordinator_optimization.py b/custom_components/battery_controller/coordinator_optimization.py index 924dfc3..df5fb41 100644 --- a/custom_components/battery_controller/coordinator_optimization.py +++ b/custom_components/battery_controller/coordinator_optimization.py @@ -49,6 +49,7 @@ MODE_HYBRID, MODE_MANUAL, MODE_ZERO_GRID, + PRICE_CHANGE_REOPTIMIZE_ABS_EUR, PRICE_CHANGE_REOPTIMIZE_THRESHOLD, STALE_SENSOR_MULTIPLIER, BATTERY_MODE_THRESHOLD_W, @@ -432,13 +433,24 @@ def _handle_price_change(self, event: Event[EventStateChangedData]) -> None: self._last_period_start = period_start self._schedule_mid_period_run(period_start, interval_minutes) self.hass.async_create_task(self.async_request_refresh()) - elif self._last_price is not None and self._last_price != 0: + elif self._last_price is not None: # Same period or no timestamp info — fallback threshold check. - change_pct = abs(new_price - self._last_price) / abs(self._last_price) - if change_pct >= PRICE_CHANGE_REOPTIMIZE_THRESHOLD: + if abs(self._last_price) > 1e-9: + change_pct = abs(new_price - self._last_price) / abs(self._last_price) + significant = change_pct >= PRICE_CHANGE_REOPTIMIZE_THRESHOLD + else: + # Relative change is undefined at a zero price (free hour): + # fall back to an absolute threshold so the trigger does not + # go dead — previously the price also never updated from 0, + # permanently disabling this fallback path. + significant = ( + abs(new_price - self._last_price) >= PRICE_CHANGE_REOPTIMIZE_ABS_EUR + ) + if significant: _LOGGER.debug( - "Significant price change: %.2f%%, triggering optimization", - change_pct * 100, + "Significant price change: %.4f -> %.4f, triggering optimization", + self._last_price, + new_price, ) self.hass.async_create_task(self.async_request_refresh()) self._last_price = new_price diff --git a/tests/test_coordinator_optimization.py b/tests/test_coordinator_optimization.py index ea6a2ae..5ce354c 100644 --- a/tests/test_coordinator_optimization.py +++ b/tests/test_coordinator_optimization.py @@ -2830,3 +2830,83 @@ def test_concentrate_redistributes_when_winner_empty(hass): result = coord._split_setpoint(-1.0, MODE_ZERO_GRID) assert result["bat1"] == pytest.approx(0.0, abs=1e-6) assert result["bat2"] == pytest.approx(-1.0, abs=1e-6) + + +@pytest.mark.asyncio +async def test_price_change_from_zero_uses_absolute_threshold(hass, monkeypatch): + """A price moving away from exactly 0 must still trigger re-optimization.""" + coordinator = _make_coordinator(hass) + + period = datetime(2026, 3, 21, 10, 0, 0, tzinfo=timezone.utc) + coordinator._last_price = 0.0 # free hour + coordinator._last_period_start = period + + monkeypatch.setattr( + "custom_components.battery_controller.coordinator_optimization.extract_price_forecast_with_timestamps", + lambda state: ([0.05], [period], 60), + ) + monkeypatch.setattr( + "custom_components.battery_controller.coordinator_optimization.dt_util.utcnow", + lambda: period + timedelta(minutes=5), + ) + + refresh_called = [] + + async def fake_refresh(): + refresh_called.append(True) + + monkeypatch.setattr(coordinator, "async_request_refresh", fake_refresh) + + old_mock = MagicMock() + old_mock.state = "0.0" + new_mock = MagicMock() + new_mock.state = "0.05" + event = MagicMock() + event.data = {"old_state": old_mock, "new_state": new_mock} + + coordinator._handle_price_change(event) + await hass.async_block_till_done() + + assert refresh_called, "absolute threshold should trigger from a zero price" + assert coordinator._last_price == pytest.approx(0.05) + + +@pytest.mark.asyncio +async def test_price_change_from_zero_small_change_updates_price(hass, monkeypatch): + """Tiny changes from 0 do not trigger, but _last_price must still update.""" + coordinator = _make_coordinator(hass) + + period = datetime(2026, 3, 21, 10, 0, 0, tzinfo=timezone.utc) + coordinator._last_price = 0.0 + coordinator._last_period_start = period + + monkeypatch.setattr( + "custom_components.battery_controller.coordinator_optimization.extract_price_forecast_with_timestamps", + lambda state: ([0.001], [period], 60), + ) + monkeypatch.setattr( + "custom_components.battery_controller.coordinator_optimization.dt_util.utcnow", + lambda: period + timedelta(minutes=5), + ) + + refresh_called = [] + + async def fake_refresh(): + refresh_called.append(True) + + monkeypatch.setattr(coordinator, "async_request_refresh", fake_refresh) + + old_mock = MagicMock() + old_mock.state = "0.0" + new_mock = MagicMock() + new_mock.state = "0.001" + event = MagicMock() + event.data = {"old_state": old_mock, "new_state": new_mock} + + coordinator._handle_price_change(event) + await hass.async_block_till_done() + + assert not refresh_called + # Previously _last_price stayed stuck at 0 because the whole branch was + # skipped for a zero price. + assert coordinator._last_price == pytest.approx(0.001)