From baac88d10d325ccfe1e92176cd0ea8031a417ad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:02:56 +0000 Subject: [PATCH] fix: tolerate null values in open-meteo hourly forecast arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-meteo occasionally returns null for individual hours, typically at the forecast edge. float(None) raised TypeError, which turned the whole weather update into UpdateFailed even though the remaining 47 hours of data were perfectly usable — degrading the PV forecast (and after the stale window, raising a repair issue) until a later poll happened to return a fully populated response. Coerce null entries to 0.0 instead. A zero radiation/wind hour is a safe neutral value, and a null temperature of 0.0 cannot trigger the >25 C panel derating. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XWQRQEJXUxbp2CGFS18U9i --- .../battery_controller/coordinator_weather.py | 17 ++++--- tests/test_coordinator_weather.py | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/custom_components/battery_controller/coordinator_weather.py b/custom_components/battery_controller/coordinator_weather.py index 486eda8..ad82572 100644 --- a/custom_components/battery_controller/coordinator_weather.py +++ b/custom_components/battery_controller/coordinator_weather.py @@ -96,30 +96,35 @@ async def _async_update_data(self) -> dict[str, Any]: start_idx = i break - # Extract next 48 hours + # Extract next 48 hours. Open-meteo occasionally returns null for + # individual hours (typically at the forecast edge); treat those as + # 0.0 instead of failing the whole update. + def _to_float(value: Any) -> float: + return float(value) if value is not None else 0.0 + try: radiation_forecast = [ - float(v) for v in radiation[start_idx : start_idx + 48] + _to_float(v) for v in radiation[start_idx : start_idx + 48] ] n = len(radiation_forecast) dni_forecast = ( - [float(v) for v in dni[start_idx : start_idx + 48]] + [_to_float(v) for v in dni[start_idx : start_idx + 48]] if dni else [0.0] * n ) diffuse_forecast = ( - [float(v) for v in diffuse[start_idx : start_idx + 48]] + [_to_float(v) for v in diffuse[start_idx : start_idx + 48]] if diffuse else [0.0] * n ) wind_speed_forecast = ( - [float(v) for v in wind_speed[start_idx : start_idx + 48]] + [_to_float(v) for v in wind_speed[start_idx : start_idx + 48]] if wind_speed else [0.0] * n ) temperature = hourly.get("temperature_2m", []) temperature_forecast = ( - [float(v) for v in temperature[start_idx : start_idx + 48]] + [_to_float(v) for v in temperature[start_idx : start_idx + 48]] if temperature else [] ) diff --git a/tests/test_coordinator_weather.py b/tests/test_coordinator_weather.py index ac4d476..c25be9e 100644 --- a/tests/test_coordinator_weather.py +++ b/tests/test_coordinator_weather.py @@ -346,3 +346,49 @@ async def test_async_update_data_invalid_radiation_value_raises_update_failed(ha with pytest.raises(UpdateFailed): await coord._async_update_data() + + +@pytest.mark.asyncio +async def test_async_update_data_null_values_treated_as_zero(hass): + """Null entries from open-meteo must not fail the whole update. + + Open-meteo occasionally returns null for individual hours (typically at + the forecast edge). Those entries become 0.0; the rest of the forecast + stays usable. + """ + response_data = _make_weather_response( + radiation=[100.0, None] + [float(100 + i) for i in range(46)], + dni=[50.0, None] + [float(50 + i) for i in range(46)], + diffuse=[20.0, None] + [float(20 + i) for i in range(46)], + wind=[3.0, None] + [3.0] * 46, + temperature=[15.0, None] + [15.0] * 46, + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value=response_data) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + with ( + patch( + "custom_components.battery_controller.coordinator_weather.async_get_clientsession", + return_value=mock_session, + ), + patch( + "custom_components.battery_controller.coordinator_weather.dt_util.utcnow", + return_value=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + ), + ): + coord = WeatherDataCoordinator(hass) + result = await coord._async_update_data() + + assert result["radiation_forecast"][0] == pytest.approx(100.0) + assert result["radiation_forecast"][1] == pytest.approx(0.0) + assert result["dni_forecast"][1] == pytest.approx(0.0) + assert result["diffuse_forecast"][1] == pytest.approx(0.0) + assert result["wind_speed_forecast"][1] == pytest.approx(0.0) + assert result["temperature_forecast"][1] == pytest.approx(0.0)