Fix: Forecast.solar PV plan shifted by local UTC offset in non-UTC timezones#3984
Open
tieskuh wants to merge 1 commit into
Open
Fix: Forecast.solar PV plan shifted by local UTC offset in non-UTC timezones#3984tieskuh wants to merge 1 commit into
tieskuh wants to merge 1 commit into
Conversation
…mezones ## 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 springfall2008#2821 (Australia/Adelaide, +10:30) and springfall2008#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 - springfall2008#2821 — Australia/Adelaide, +10:30 offset, plan showed PV during night hours - springfall2008#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 springfall2008#2856 / springfall2008#2911, but the underlying `.replace(tzinfo=pytz.utc)` on the local-tz `midnight_utc` was kept and is the remaining root cause.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
solcast.pyline 508 incorrectly applies.replace(tzinfo=pytz.utc)toself.midnight_utc, which is already a timezone-aware datetime inlocal_tz(not UTC, despite the misleading name). This produces a finalperiod_start_stampthat 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:So
self.midnight_utcis 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:.replace(tzinfo=...)on a tz-aware datetime keeps the wall-clock time and swaps the label. So00:00 CESTbecomes00:00 UTC— a different absolute moment, 2 hours later. Every subsequentperiod_start_stampis 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 (fromstrptime(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: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_utcis already tz-aware inlocal_tz; adding atimedeltapreserves the timezone:After fix:
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:00for UTC midnight + 1184 min. Tested with the same reproduction script usingtzinfo=pytz.utcformidnight_utc. Verified delta = 0 seconds.Related issues
Both were partially addressed by adding
?time=utcto the URL and thestrptimechange in #2856 / #2911, but the underlying.replace(tzinfo=pytz.utc)on the local-tzmidnight_utcwas kept and is the remaining root cause.