Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions custom_components/battery_controller/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
22 changes: 17 additions & 5 deletions custom_components/battery_controller/coordinator_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tests/test_coordinator_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading