Skip to content

Commit 6b679f4

Browse files
Merge pull request #503 from paulhomes/weather
Cache Open-Meteo JSON reponse locally to reduce the number of API calls and add resilience
2 parents 97150db + b68c043 commit 6b679f4

6 files changed

Lines changed: 110 additions & 26 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ MANIFEST
4747

4848
# Local session data
4949
data/actionLogs.txt
50+
data/debug-*.csv
51+
data/cached-open-meteo-forecast.json
5052
data/entities/*.json
5153

5254
# PyInstaller

docs/forecasts.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@ curl -i -H 'Content-Type:application/json' -X POST -d {} http://localhost:5000/a
9494
curl -i -H 'Content-Type:application/json' -X POST -d '{"weather_forecast_cache_only":true}' http://localhost:5000/action/naive-mpc-optim
9595
```
9696

97+
#### Caching Open-Meteo Weather Service Usage
98+
99+
When you have EMHASS configured to use the Open-Meteo weather service, to minimize API calls to the service, and to provide
100+
resilience in case of transient connectivity issues, EMHASS caches successful calls to the Open-Meteo API in a
101+
`cached-open-meteo-forecast.json` file in the data directory. The JSON file contains the default 3 days of weather forecast data.
102+
This Open-Meteo cache is independent of the PV cache discussed above and will be used even when the PV cache is not enabled.
103+
By default, when the JSON file is older than 30 minutes, attempts will be made to replace it with a more recent version
104+
from the Open-Meteo weather service. It will only be replaced if this is successful. If any errors occur the older version
105+
will continue to be used until a new version can been fetched. The maximum cache age, with a default value of 30 minutes, can be
106+
configured using the `open_meteo_cache_max_age` setting in config.json or as a parameter in EMHASS REST API calls.
107+
The value is specified in minutes. If you want to disable caching you can specify a value of 0.
108+
97109
#### Adjusting PV Forecasts using machine learning
98110
EMHASS provides methods to adjust the PV power forecast using machine learning regression techniques. The adjustment process consists of two steps: training a regression model using historical PV data and then applying the trained model to correct new PV forecasts.
99111

src/emhass/data/associations.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ optim_conf,battery_dynamic_min,battery_dynamic_min
4646
optim_conf,weight_battery_discharge,weight_battery_discharge
4747
optim_conf,weight_battery_charge,weight_battery_charge
4848
optim_conf,weather_forecast_method,weather_forecast_method
49+
optim_conf,open_meteo_cache_max_age,open_meteo_cache_max_age
4950
optim_conf,def_start_timestep,start_timesteps_of_each_deferrable_load,list_start_timesteps_of_each_deferrable_load
5051
optim_conf,def_end_timestep,end_timesteps_of_each_deferrable_load,list_end_timesteps_of_each_deferrable_load
5152
optim_conf,list_hp_periods,load_peak_hour_periods

src/emhass/data/config_defaults.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
0
4444
],
4545
"weather_forecast_method": "open-meteo",
46+
"open_meteo_cache_max_age": 30,
4647
"load_forecast_method": "naive",
4748
"delta_forecast_daily": 1,
4849
"load_cost_forecast_method": "hp_hc_periods",

src/emhass/forecast.py

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pvlib.pvsystem import PVSystem
2424
from pvlib.temperature import TEMPERATURE_MODEL_PARAMETERS
2525
from requests import get
26+
from requests.exceptions import RequestException
2627

2728
from sklearn.pipeline import make_pipeline
2829
from sklearn.preprocessing import StandardScaler
@@ -215,6 +216,92 @@ def __init__(
215216
0 : self.params["passed_data"]["prediction_horizon"]
216217
]
217218

219+
def get_cached_open_meteo_forecast_json(
220+
self,
221+
max_age: Optional[int] = 30,
222+
) -> dict:
223+
r"""
224+
Get weather forecast json from Open-Meteo and cache it for re-use.
225+
The response json is cached in the local file system and returned
226+
on subsequent calls until it is older than max_age, at which point
227+
attempts will be made to replace it with a new version.
228+
The cached version will not be overwritten until a new version has
229+
been successfully fetched from Open-Meteo.
230+
In the event of connectivity issues, the cached version will continue
231+
to be returned until such time as a new version can be successfully
232+
fetched from Open-Meteo.
233+
If you want to force reload, pass max_age value of zero.
234+
235+
:param max_age: The maximum age of the cached json file, in minutes,
236+
before it is discarded and a new version fetched from Open-Meteo.
237+
Defaults to 30 minutes.
238+
:type max_age: int, optional
239+
:return: The json containing the Open-Meteo forecast data
240+
:rtype: dict
241+
242+
"""
243+
json_path = os.path.abspath(
244+
self.emhass_conf["data_path"] / "cached-open-meteo-forecast.json"
245+
)
246+
# The cached JSON file is always loaded, if it exists, as it is also a fallback
247+
# in case the REST API call to Open-Meteo fails - the cached JSON will continue to
248+
# be used until it can successfully fetch a new version from Open-Meteo.
249+
data = None
250+
use_cache = False
251+
if os.path.exists(json_path):
252+
delta = datetime.now() - datetime.fromtimestamp(os.path.getmtime(json_path))
253+
json_age = int(delta / timedelta(seconds=60))
254+
use_cache = json_age < max_age
255+
self.logger.info("Loading existing cached Open-Meteo JSON file: %s", json_path)
256+
with open(json_path) as json_file:
257+
data = json.load(json_file)
258+
if use_cache:
259+
self.logger.info("The cached Open-Meteo JSON file is recent (age=%.0fm, max_age=%sm)",
260+
json_age, max_age)
261+
else:
262+
self.logger.info("The cached Open-Meteo JSON file is old (age=%.0fm, max_age=%sm)",
263+
json_age, max_age)
264+
265+
if not use_cache:
266+
self.logger.info("Fetching a new weather forecast from Open-Meteo")
267+
headers = {"User-Agent": "EMHASS", "Accept": "application/json"}
268+
url = (
269+
"https://api.open-meteo.com/v1/forecast?"
270+
+ "latitude="
271+
+ str(round(self.lat, 2))
272+
+ "&longitude="
273+
+ str(round(self.lon, 2))
274+
+ "&minutely_15="
275+
+ "temperature_2m,"
276+
+ "relative_humidity_2m,"
277+
+ "rain,"
278+
+ "cloud_cover,"
279+
+ "wind_speed_10m,"
280+
+ "shortwave_radiation_instant,"
281+
+ "diffuse_radiation_instant,"
282+
+ "direct_normal_irradiance_instant"
283+
+ "&timezone="
284+
+ quote(str(self.time_zone), safe="")
285+
)
286+
try:
287+
response = get(url, headers=headers)
288+
self.logger.debug("Returned HTTP status code: %s", response.status_code)
289+
response.raise_for_status()
290+
"""import bz2 # Uncomment to save a serialized data for tests
291+
import _pickle as cPickle
292+
with bz2.BZ2File("data/test_response_openmeteo_get_method.pbz2", "w") as f:
293+
cPickle.dump(response, f)"""
294+
data = response.json()
295+
self.logger.info("Saving response in Open-Meteo JSON cache file: %s", json_path)
296+
with open(json_path, "w") as json_file:
297+
json.dump(response.json(), json_file, indent=2)
298+
except RequestException:
299+
self.logger.error("Failed to fetch weather forecast from Open-Meteo", exc_info=True)
300+
if data is not None:
301+
self.logger.warning("Returning old cached data until next Open-Meteo attempt")
302+
303+
return data
304+
218305
def get_weather_forecast(
219306
self,
220307
method: Optional[str] = "open-meteo",
@@ -248,32 +335,7 @@ def get_weather_forecast(
248335
method == "open-meteo" or method == "scrapper"
249336
): # The scrapper option is being left here for backward compatibility
250337
if not os.path.isfile(w_forecast_cache_path):
251-
headers = {"User-Agent": "EMHASS", "Accept": "application/json"}
252-
url = (
253-
"https://api.open-meteo.com/v1/forecast?"
254-
+ "latitude="
255-
+ str(round(self.lat, 2))
256-
+ "&longitude="
257-
+ str(round(self.lon, 2))
258-
+ "&minutely_15="
259-
+ "temperature_2m,"
260-
+ "relative_humidity_2m,"
261-
+ "rain,"
262-
+ "cloud_cover,"
263-
+ "wind_speed_10m,"
264-
+ "shortwave_radiation_instant,"
265-
+ "diffuse_radiation_instant,"
266-
+ "direct_normal_irradiance_instant"
267-
+ "&timezone="
268-
+ quote(str(self.time_zone), safe="")
269-
)
270-
response = get(url, headers=headers)
271-
"""import bz2 # Uncomment to save a serialized data for tests
272-
import _pickle as cPickle
273-
with bz2.BZ2File("data/test_response_openmeteo_get_method.pbz2", "w") as f:
274-
cPickle.dump(response, f)"""
275-
276-
data_raw = response.json()
338+
data_raw = self.get_cached_open_meteo_forecast_json(self.optim_conf["open_meteo_cache_max_age"])
277339
data_15min = pd.DataFrame.from_dict(data_raw["minutely_15"])
278340
data_15min["time"] = pd.to_datetime(data_15min["time"])
279341
data_15min.set_index("time", inplace=True)

src/emhass/static/data/param_definitions.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@
150150
],
151151
"default_value": "open-meteo"
152152
},
153+
"open_meteo_cache_max_age": {
154+
"friendly_name": "Open-Meteo Cache Max Age",
155+
"Description": "The maximum age, in minutes, of the cached open-meteo json response, after which a new version will be fetched from Open-Meteo. Defaults to 30.",
156+
"input": "int",
157+
"default_value": 30
158+
},
153159
"maximum_power_from_grid": {
154160
"friendly_name": "Max power from grid",
155161
"Description": "The maximum power that can be supplied by the utility grid in Watts (consumption). Defaults to 9000.",

0 commit comments

Comments
 (0)