Skip to content

Commit 608021d

Browse files
Merge pull request #4030 from springfall2008/feat/open_meteo_backup
Feat, add open meteo backup for forecast.solar
2 parents 0780408 + 69aebd4 commit 608021d

7 files changed

Lines changed: 213 additions & 18 deletions

File tree

apps/predbat/components.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"solcast_poll_hours": {"required": False, "config": "solcast_poll_hours", "default": 8},
104104
"forecast_solar": {"required": False, "config": "forecast_solar", "default": False},
105105
"forecast_solar_max_age": {"required": False, "config": "forecast_solar_max_age", "default": 8},
106+
"forecast_solar_open_meteo_backup": {"required": False, "config": "forecast_solar_open_meteo_backup", "default": False},
106107
"pv_forecast_today": {"required": False, "config": "pv_forecast_today"},
107108
"pv_forecast_tomorrow": {"required": False, "config": "pv_forecast_tomorrow"},
108109
"pv_forecast_d3": {"required": False, "config": "pv_forecast_d3"},

apps/predbat/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2218,6 +2218,7 @@
22182218
"predheat": {"type": "dict"},
22192219
"forecast_solar": {"type": "dict_list"},
22202220
"forecast_solar_max_age": {"type": "float"},
2221+
"forecast_solar_open_meteo_backup": {"type": "boolean"},
22212222
"open_meteo_forecast": {"type": "dict_list"},
22222223
"open_meteo_forecast_max_age": {"type": "float"},
22232224
"enable_coarse_fine_levels": {"type": "boolean"},

apps/predbat/predbat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
import requests
3737
import asyncio
3838

39-
THIS_VERSION = "v8.40.3"
39+
THIS_VERSION = "v8.40.4"
4040

4141
from download import predbat_update_move, predbat_update_download, check_install, resolve_predbat_repository, DEFAULT_PREDBAT_REPOSITORY
4242
from const import MINUTE_WATT

apps/predbat/solcast.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def initialize(
6969
solcast_poll_hours,
7070
forecast_solar,
7171
forecast_solar_max_age,
72+
forecast_solar_open_meteo_backup,
7273
pv_forecast_today,
7374
pv_forecast_tomorrow,
7475
pv_forecast_d3,
@@ -84,6 +85,7 @@ def initialize(
8485
self.solcast_poll_hours = solcast_poll_hours
8586
self.forecast_solar = forecast_solar
8687
self.forecast_solar_max_age = forecast_solar_max_age
88+
self.forecast_solar_open_meteo_backup = forecast_solar_open_meteo_backup
8789
self.pv_forecast_today = pv_forecast_today
8890
self.pv_forecast_tomorrow = pv_forecast_tomorrow
8991
self.pv_forecast_d3 = pv_forecast_d3
@@ -283,16 +285,18 @@ async def download_open_meteo_ensemble_data(self, lat, lon, tilt, az, kwp, syste
283285
result[ts] = dp4((gti_p10 / 1000.0) * kwp * (1.0 - system_loss))
284286
return result
285287

286-
async def download_open_meteo_data(self):
288+
async def download_open_meteo_data(self, configs=None):
287289
"""
288290
Download Open-Meteo forecast data and convert to PV power estimates.
289291
Uses GTI (global tilted irradiance) with simple temperature derating for P50,
290292
and ensemble members for P10. Returns (sorted_data, max_kwh).
293+
If configs is provided it is used directly; otherwise self.open_meteo_forecast is used.
291294
"""
292295
period_data = {}
293296
max_kwh = 0
294297

295-
configs = self.open_meteo_forecast
298+
if configs is None:
299+
configs = self.open_meteo_forecast
296300
if configs is None:
297301
raise ValueError("SolarAPI: No Open-Meteo forecast configurations found")
298302
if not isinstance(configs, list):
@@ -1258,6 +1262,10 @@ async def fetch_pv_forecast(self):
12581262
pv_forecast_data, max_kwh = await self.download_forecast_solar_data()
12591263
divide_by = 30.0
12601264
create_pv10 = True
1265+
if not pv_forecast_data and self.forecast_solar_open_meteo_backup:
1266+
self.log("SolarAPI: Forecast Solar returned no data, falling back to Open-Meteo backup")
1267+
backup_configs = self.open_meteo_forecast if self.open_meteo_forecast else self.forecast_solar
1268+
pv_forecast_data, max_kwh = await self.download_open_meteo_data(configs=backup_configs)
12611269
elif self.open_meteo_forecast:
12621270
self.log("SolarAPI: Obtaining solar forecast from Open-Meteo API")
12631271
pv_forecast_data, max_kwh = await self.download_open_meteo_data()

apps/predbat/tests/test_random_scenarios.py

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,54 @@
4343
PV_PEAK_OPTIONS_KW = [0.0, 1.0, 2.0, 4.0, 6.0, 10.0]
4444

4545
RATE_TYPES = ["single", "dual", "triple", "halfhourly"]
46+
IMPORT_RATE_TYPES = RATE_TYPES + ["negative_halfhourly"]
4647

4748
# ---------------------------------------------------------------------------
4849
# Daily profile generators (return list[float] with exactly 1440 elements)
4950
# ---------------------------------------------------------------------------
5051

5152

53+
def _smooth_rate_profile(control_points, allow_negative=False):
54+
"""Cosine-interpolate between control points to produce a 1440-minute rate profile.
55+
56+
Adjacent control points are connected with a smooth S-curve (raised cosine), so the
57+
profile ramps gradually between each peak/trough rather than jumping abruptly.
58+
59+
Args:
60+
control_points: list of (minute, rate) pairs covering at least minute 0 and 1439
61+
allow_negative: if False, clamp all output values to >= 0
62+
63+
Returns:
64+
list[float] of 1440 values
65+
"""
66+
pts = sorted(control_points, key=lambda x: x[0])
67+
profile = []
68+
idx = 0
69+
n = len(pts)
70+
for m in range(MINUTES_PER_DAY):
71+
while idx + 1 < n and pts[idx + 1][0] <= m:
72+
idx += 1
73+
t0, v0 = pts[idx]
74+
if idx + 1 >= n:
75+
val = v0
76+
else:
77+
t1, v1 = pts[idx + 1]
78+
span = t1 - t0
79+
mu = (m - t0) / span if span > 0 else 1.0
80+
val = v0 + (v1 - v0) * (1.0 - math.cos(mu * math.pi)) / 2.0
81+
if not allow_negative:
82+
val = max(0.0, val)
83+
profile.append(val)
84+
return profile
85+
86+
5287
def generate_rates_day(rate_type, params, seed):
5388
"""Generate a single-day (1440-minute) per-minute rate profile in p/kWh.
5489
5590
Args:
56-
rate_type: One of "single", "dual", "triple", "halfhourly"
91+
rate_type: One of "single", "dual", "triple", "halfhourly", "negative_halfhourly"
5792
params: dict of rate parameters (see per-type docs below)
58-
seed: integer seed for halfhourly random generation
93+
seed: integer seed for halfhourly/negative_halfhourly random generation
5994
6095
Returns:
6196
list[float] of 1440 values
@@ -95,15 +130,21 @@ def generate_rates_day(rate_type, params, seed):
95130
else:
96131
profile[m] = shoulder_rate
97132

98-
elif rate_type == "halfhourly":
99-
# 48 half-hourly slots, randomly priced, each slot is 30 minutes
133+
elif rate_type in ("halfhourly", "negative_halfhourly"):
134+
# Smooth profile: cosine-interpolate between randomly placed peaks and troughs
135+
allow_negative = rate_type == "negative_halfhourly"
100136
rng = random.Random(seed)
101-
base = float(params.get("base_rate", 10.0))
102-
spread = float(params.get("spread", 20.0))
103-
slots = [max(0.0, base + rng.uniform(-spread / 2, spread / 2)) for _ in range(48)]
104-
for m in range(MINUTES_PER_DAY):
105-
slot = m // 30
106-
profile[m] = slots[slot]
137+
base = float(params.get("base_rate", 15.0))
138+
low = float(params.get("low_rate", 5.0))
139+
high = float(params.get("high_rate", 30.0))
140+
num_highs = int(params.get("num_highs", 2))
141+
num_lows = int(params.get("num_lows", 2))
142+
control_points = [(0, base), (MINUTES_PER_DAY - 1, base)]
143+
for _ in range(num_highs):
144+
control_points.append((rng.randint(0, MINUTES_PER_DAY - 1), high))
145+
for _ in range(num_lows):
146+
control_points.append((rng.randint(0, MINUTES_PER_DAY - 1), low))
147+
profile = _smooth_rate_profile(control_points, allow_negative)
107148

108149
return profile
109150

@@ -228,7 +269,7 @@ def generate_random_scenario(scenario_id, seed):
228269
sunset_minute = rng.randint(PV_SUNSET_MINUTE_MIN, PV_SUNSET_MINUTE_MAX)
229270

230271
# --- Import rate ---
231-
import_rate_type = rng.choice(RATE_TYPES)
272+
import_rate_type = rng.choice(IMPORT_RATE_TYPES)
232273
import_rate_params = _sample_rate_params(rng, import_rate_type, cheap=True)
233274
import_rate_seed = rng.randint(0, 2**31)
234275

@@ -1039,7 +1080,19 @@ def _sample_rate_params(rng, rate_type, cheap):
10391080
"peak_end_hhmm": "{}:00".format(peak_end_hour),
10401081
}
10411082

1042-
else: # halfhourly
1043-
base = round(rng.uniform(10.0, 25.0), 2)
1044-
spread = round(rng.uniform(10.0, 30.0), 2)
1045-
return {"base_rate": base, "spread": spread}
1083+
elif rate_type == "halfhourly":
1084+
if cheap:
1085+
base = round(rng.uniform(15.0, 25.0), 2)
1086+
low = round(rng.uniform(3.0, 12.0), 2)
1087+
high = round(rng.uniform(30.0, 50.0), 2)
1088+
else:
1089+
base = round(rng.uniform(8.0, 18.0), 2)
1090+
low = round(rng.uniform(2.0, 8.0), 2)
1091+
high = round(rng.uniform(18.0, 30.0), 2)
1092+
return {"base_rate": base, "low_rate": low, "high_rate": high, "num_highs": rng.randint(1, 3), "num_lows": rng.randint(1, 3)}
1093+
1094+
else: # negative_halfhourly — troughs go negative to test plunge-pricing scenarios
1095+
base = round(rng.uniform(5.0, 15.0), 2)
1096+
low = round(rng.uniform(-15.0, 0.0), 2)
1097+
high = round(rng.uniform(25.0, 50.0), 2)
1098+
return {"base_rate": base, "low_rate": low, "high_rate": high, "num_highs": rng.randint(1, 3), "num_lows": rng.randint(1, 3)}

apps/predbat/tests/test_solcast.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ def __init__(self):
167167
solcast_poll_hours=4,
168168
forecast_solar=None,
169169
forecast_solar_max_age=4,
170+
forecast_solar_open_meteo_backup=False,
170171
pv_forecast_today=None,
171172
pv_forecast_tomorrow=None,
172173
pv_forecast_d3=None,
@@ -1712,6 +1713,119 @@ def create_mock_session(*args, **kwargs):
17121713
return failed
17131714

17141715

1716+
def test_fetch_pv_forecast_forecast_solar_open_meteo_backup_on_failure(my_predbat):
1717+
"""
1718+
When forecast.solar returns no data and forecast_solar_open_meteo_backup is True,
1719+
fetch_pv_forecast falls back to Open-Meteo.
1720+
"""
1721+
print(" - test_fetch_pv_forecast_forecast_solar_open_meteo_backup_on_failure")
1722+
failed = False
1723+
1724+
test_api = create_test_solar_api()
1725+
try:
1726+
test_api.solar.forecast_solar = [{"latitude": 51.5, "longitude": -0.1, "declination": 30, "azimuth": 0, "kwp": 3.0}]
1727+
test_api.solar.forecast_solar_open_meteo_backup = True
1728+
test_api.solar.open_meteo_forecast_max_age = 1.0
1729+
# forecast.solar returns a server error — download_forecast_solar_data returns ([], 0)
1730+
test_api.set_mock_response("forecast.solar", {"error": "server error"}, 500)
1731+
# Open-Meteo returns valid hourly data
1732+
test_api.set_mock_response(
1733+
"api.open-meteo.com",
1734+
{
1735+
"hourly": {
1736+
"time": ["2025-06-15T12:00", "2025-06-15T13:00", "2025-06-15T14:00"],
1737+
"global_tilted_irradiance": [500.0, 600.0, 550.0],
1738+
"temperature_2m": [25.0, 25.0, 25.0],
1739+
"wind_speed_10m": [1.0, 1.0, 1.0],
1740+
}
1741+
},
1742+
)
1743+
test_api.set_mock_response(
1744+
"ensemble-api.open-meteo.com",
1745+
{
1746+
"hourly": {
1747+
"time": ["2025-06-15T12:00", "2025-06-15T13:00", "2025-06-15T14:00"],
1748+
"global_tilted_irradiance_member01": [400.0, 480.0, 440.0],
1749+
}
1750+
},
1751+
)
1752+
1753+
def create_mock_session(*args, **kwargs):
1754+
"""Create a mock aiohttp session."""
1755+
return test_api.mock_aiohttp_session()
1756+
1757+
with patch("solcast.aiohttp.ClientSession", side_effect=create_mock_session):
1758+
run_async(test_api.solar.fetch_pv_forecast())
1759+
1760+
# Open-Meteo should have been called (fallback activated)
1761+
open_meteo_calls = [r for r in test_api.request_log if "open-meteo.com" in r["url"]]
1762+
if len(open_meteo_calls) == 0:
1763+
print("ERROR: Expected Open-Meteo API call during fallback, got none")
1764+
failed = True
1765+
1766+
# Forecast data should have been published (came from Open-Meteo)
1767+
if f"sensor.{test_api.mock_base.prefix}_pv_today" not in test_api.dashboard_items:
1768+
print("ERROR: Expected pv_today sensor to be published after Open-Meteo fallback")
1769+
failed = True
1770+
1771+
finally:
1772+
test_api.cleanup()
1773+
1774+
return failed
1775+
1776+
1777+
def test_fetch_pv_forecast_forecast_solar_open_meteo_backup_not_used_on_success(my_predbat):
1778+
"""
1779+
When forecast.solar returns data successfully, Open-Meteo backup is not called
1780+
even when forecast_solar_open_meteo_backup is True.
1781+
"""
1782+
print(" - test_fetch_pv_forecast_forecast_solar_open_meteo_backup_not_used_on_success")
1783+
failed = False
1784+
1785+
test_api = create_test_solar_api()
1786+
try:
1787+
test_api.solar.forecast_solar = [{"latitude": 51.5, "longitude": -0.1, "declination": 30, "azimuth": 0, "kwp": 3.0}]
1788+
test_api.solar.forecast_solar_open_meteo_backup = True
1789+
# forecast.solar returns valid data
1790+
test_api.set_mock_response(
1791+
"forecast.solar",
1792+
{
1793+
"result": {
1794+
"watts": {
1795+
"2025-06-15T12:00:00+0000": 500,
1796+
"2025-06-15T12:30:00+0000": 600,
1797+
}
1798+
},
1799+
"message": {"info": {"time": "2025-06-15T11:30:00+0000"}},
1800+
},
1801+
200,
1802+
)
1803+
1804+
def create_mock_session(*args, **kwargs):
1805+
"""Create a mock aiohttp session."""
1806+
return test_api.mock_aiohttp_session()
1807+
1808+
with patch("solcast.aiohttp.ClientSession", side_effect=create_mock_session):
1809+
run_async(test_api.solar.fetch_pv_forecast())
1810+
1811+
# Open-Meteo should NOT have been called
1812+
open_meteo_calls = [r for r in test_api.request_log if "open-meteo.com" in r["url"]]
1813+
if len(open_meteo_calls) != 0:
1814+
print(f"ERROR: Expected no Open-Meteo calls when forecast.solar succeeds, got {len(open_meteo_calls)}")
1815+
failed = True
1816+
1817+
# Forecast.Solar should have been called and data published
1818+
forecast_calls = [r for r in test_api.request_log if "forecast.solar" in r["url"]]
1819+
if len(forecast_calls) == 0:
1820+
print("ERROR: Expected Forecast.Solar API call, got none")
1821+
failed = True
1822+
1823+
finally:
1824+
test_api.cleanup()
1825+
1826+
return failed
1827+
1828+
17151829
def test_fetch_pv_forecast_ha_sensors(my_predbat):
17161830
"""
17171831
Integration test: fetch_pv_forecast using HA sensors (Solcast integration).
@@ -3270,6 +3384,8 @@ def run_solcast_tests(my_predbat):
32703384
# Integration tests (one per mode)
32713385
failed |= test_fetch_pv_forecast_solcast_direct(my_predbat)
32723386
failed |= test_fetch_pv_forecast_forecast_solar(my_predbat)
3387+
failed |= test_fetch_pv_forecast_forecast_solar_open_meteo_backup_on_failure(my_predbat)
3388+
failed |= test_fetch_pv_forecast_forecast_solar_open_meteo_backup_not_used_on_success(my_predbat)
32733389
failed |= test_fetch_pv_forecast_ha_sensors(my_predbat)
32743390

32753391
# 15-minute resolution tests

docs/apps-yaml.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,6 +1460,22 @@ Optionally you can set an api_key for personal or professional accounts and you
14601460

14611461
Note you can omit any of these settings for a default value. They do not have to be exact if you use Predbat auto calibration for PV to improve the data quality.
14621462

1463+
### Open-Meteo backup for Forecast.solar
1464+
1465+
If you set `forecast_solar_open_meteo_backup: true`, Predbat will automatically fall back to the [Open-Meteo](#open-meteo-solar-forecast) API whenever Forecast.solar returns no data (for example, due to a server error, rate limiting, or an outage).
1466+
1467+
When the fallback is active, Predbat derives the Open-Meteo request from the same `forecast_solar` configuration entries (latitude, longitude, postcode, declination, azimuth, kwp, efficiency), so no extra configuration is needed. If you also have an `open_meteo_forecast` section configured, that configuration is used for the backup request instead, which lets you apply Open-Meteo-specific options such as `shading_factors`.
1468+
1469+
```yaml
1470+
forecast_solar:
1471+
- postcode: SW1A 2AB
1472+
kwp: 3
1473+
azimuth: 45
1474+
declination: 45
1475+
efficiency: 0.95
1476+
forecast_solar_open_meteo_backup: true
1477+
```
1478+
14631479
## Open-Meteo Solar Forecast
14641480

14651481
[Open-Meteo](https://open-meteo.com/) is a free, open-source weather API that provides solar irradiance forecasts with no API key required.

0 commit comments

Comments
 (0)