From f43df2f1444fe92c9a23f942a5fa83a151d6c9fe Mon Sep 17 00:00:00 2001 From: Matthijs <115901851+tieskuh@users.noreply.github.com> Date: Sun, 31 May 2026 00:18:28 +0200 Subject: [PATCH] Fix: Forecast.solar PV plan shifted by local UTC offset in non-UTC timezones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `solcast.py` line 508 incorrectly applies `.replace(tzinfo=pytz.utc)` to `self.midnight_utc`, which is already a timezone-aware datetime in `local_tz` (not UTC, despite the misleading name). This produces a final `period_start_stamp` that is shifted forward by the local UTC offset. For users east of UTC (CET +1, CEST +2, Adelaide +10:30, Auckland +13), Forecast.solar data appears later in the plan than it should — and west of UTC, earlier. This is the root cause of the symptoms reported in #2821 (Australia/Adelaide, +10:30) and #2911 (Pacific/Auckland, +13) and is still reproducible in v8.39.7 in Europe/Amsterdam (+2 CEST) where the smaller offset makes the bug look like "the PV tail extends past sunset" rather than complete day inversion. ## Root cause In `predbat.py:738-747`: ```python self.now_utc_real = datetime.now(self.local_tz) # tz-aware in local_tz now_utc = self.now_utc_real + timedelta(minutes=skew) # still tz-aware in local_tz ... self.now_utc = now_utc # name says UTC, value is local_tz self.midnight_utc = now_utc.replace(hour=0, ...) # local midnight in local_tz ``` So `self.midnight_utc` is the start of the local day, *as a timezone-aware datetime in local_tz* — not UTC midnight. The name is misleading but the value is correct for the way it's used elsewhere (e.g. `(period_end_stamp - self.midnight_utc)` works correctly because Python normalizes tz-aware subtraction to absolute time). The bug at `solcast.py:508`: ```python period_start_stamp = self.midnight_utc.replace(tzinfo=pytz.utc) + timedelta(minutes=minute) ``` `.replace(tzinfo=...)` on a tz-aware datetime **keeps the wall-clock time and swaps the label**. So `00:00 CEST` becomes `00:00 UTC` — a different absolute moment, 2 hours later. Every subsequent `period_start_stamp` is shifted by that local UTC offset. The other use of `.replace(tzinfo=pytz.utc)` on line 376 (Open-Meteo path) is correct because the stamp there is genuinely naive (from `strptime(ts, "%Y-%m-%dT%H:%M")` without `%z`). The bug only affects the Forecast.solar code path. ## Reproduction (Europe/Amsterdam, 30 May 2026) Forecast.solar API returns sunset at `"2026-05-30T19:44:12+00:00"` = 21:44 CEST. With current code: ``` midnight_utc: 2026-05-30 00:00:00+02:00 (local midnight, in CEST) sunset stamp: 2026-05-30 19:44:12+00:00 (UTC from cache) minutes_end: 1304.2 min (21h44m since 00:00 CEST) period_start_stamp (BUG): 2026-05-30 21:44:00+00:00 (00:00 + 1304 min, but tz forced to UTC) -> displayed as 23:44 CEST in plan ← WRONG, sunset is 21:44 ``` User-visible: Predbat plan shows non-zero PV until 23:00-23:30 local, ~2 hours after astronomical sunset. Same data via the Home Assistant Forecast.solar integration sensor (`sensor.energy_production_today`) renders correctly. ## Fix Drop the `.replace(tzinfo=pytz.utc)`. `self.midnight_utc` is already tz-aware in `local_tz`; adding a `timedelta` preserves the timezone: ```diff --- a/apps/predbat/solcast.py +++ b/apps/predbat/solcast.py @@ -505,7 +505,7 @@ class SolarAPI(ComponentBase): for offset in range(0, self.plan_interval_minutes, 1): pv50 += dp4(forecast_watt_data.get(minute + offset, 0) / 1000.0) pv50 /= 60 - period_start_stamp = self.midnight_utc.replace(tzinfo=pytz.utc) + timedelta(minutes=minute) + period_start_stamp = self.midnight_utc + timedelta(minutes=minute) data_item = {"period_start": period_start_stamp.strftime(TIME_FORMAT), "pv_estimate": pv50} if period_start_stamp in period_data: period_data[period_start_stamp]["pv_estimate"] += pv50 ``` After fix: ``` period_start_stamp (FIX): 2026-05-30 21:44:00+02:00 (00:00 + 1304 min in CEST) -> displayed as 21:44 CEST in plan ← CORRECT ``` ## Regression check For users in UTC the fix is a no-op. Both old and new code produce `2026-05-30 19:44:00+00:00` for UTC midnight + 1184 min. Tested with the same reproduction script using `tzinfo=pytz.utc` for `midnight_utc`. Verified delta = 0 seconds. ## Related issues - #2821 — Australia/Adelaide, +10:30 offset, plan showed PV during night hours - #2911 — Pacific/Auckland, +13 offset, plan entirely shifted by 13h Both were partially addressed by adding `?time=utc` to the URL and the `strptime` change in #2856 / #2911, but the underlying `.replace(tzinfo=pytz.utc)` on the local-tz `midnight_utc` was kept and is the remaining root cause. --- apps/predbat/solcast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/solcast.py b/apps/predbat/solcast.py index 1b518bcb2..1b30348e3 100644 --- a/apps/predbat/solcast.py +++ b/apps/predbat/solcast.py @@ -505,7 +505,7 @@ async def download_forecast_solar_data(self): for offset in range(0, self.plan_interval_minutes, 1): pv50 += dp4(forecast_watt_data.get(minute + offset, 0) / 1000.0) pv50 /= 60 - period_start_stamp = self.midnight_utc.replace(tzinfo=pytz.utc) + timedelta(minutes=minute) + period_start_stamp = self.midnight_utc + timedelta(minutes=minute) data_item = {"period_start": period_start_stamp.strftime(TIME_FORMAT), "pv_estimate": pv50} if period_start_stamp in period_data: period_data[period_start_stamp]["pv_estimate"] += pv50