Skip to content

Fix: Forecast.solar PV plan shifted by local UTC offset in non-UTC timezones#3984

Open
tieskuh wants to merge 1 commit into
springfall2008:mainfrom
tieskuh:patch-1
Open

Fix: Forecast.solar PV plan shifted by local UTC offset in non-UTC timezones#3984
tieskuh wants to merge 1 commit into
springfall2008:mainfrom
tieskuh:patch-1

Conversation

@tieskuh
Copy link
Copy Markdown

@tieskuh tieskuh commented May 30, 2026

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:

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:

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:

--- 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

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.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant