Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,17 @@ Add the following entries to your config:

```ini
# Select the weather provider to use: "OWM" (OpenWeatherMap) or "WAPI" (Weather API)
WEATHER_PROVIDER = OWM
WEATHER_PROVIDER = "OWM"

# API key for the selected weather provider
WEATHERAPI_KEY = your-api-key-here
WEATHERAPI_KEY = "your-api-key-here"

# Name to register the weather data source in FlexMeasures. The default is 'Weather'.
# Examples: "OpenWeatherMap" (for backwards compatibility with the OWM plugin).
WEATHER_DATA_SOURCE_NAME = 'OpenWeatherMap'
WEATHER_DATA_SOURCE_NAME = "OpenWeatherMap"

# File path to store weather data in JSON format
WEATHER_FILE_PATH_LOCATION = /path/to/weather_output.json
WEATHER_FILE_PATH_LOCATION = "/path/to/weather_output.json"
```

### Extending to Other Weather API Services
Expand All @@ -91,7 +91,21 @@ To expand the plugin's coverage to additional weather API services:

This function should return data in the same structure as used by the original OpenWeatherMap integration, and **must have at least 48 hours of forecast data from the time of the call**.

3. **Integrate into the plugin**
You also need a provider-specific mapping entry in `flexmeasures_weather/sensor_specs.py`. Each supported sensor should include the new provider's response field name, for example:

```python
dict(
fm_sensor_name="temperature",
OWM_sensor_name="temp",
WAPI_sensor_name="temp_c",
NEWAPI_sensor_name="temperatureC",
unit="°C",
event_resolution=timedelta(minutes=60),
attributes=weather_attributes,
)
```

3. **Integrate into the plugin**
Modify the `call_api` function in the `weather.py` file to include a conditional branch for the new provider:

```python
Expand Down
3 changes: 2 additions & 1 deletion flexmeasures_weather/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ def add_weather_sensor(**args):
fm_sensor_specs["generic_asset"] = weather_station
fm_sensor_specs["timezone"] = args["timezone"]
fm_sensor_specs["name"] = fm_sensor_specs.pop("fm_sensor_name")
fm_sensor_specs.pop("weather_sensor_name")
fm_sensor_specs.pop("OWM_sensor_name")
fm_sensor_specs.pop("WAPI_sensor_name")
sensor = Sensor(**fm_sensor_specs)
sensor.attributes = fm_sensor_specs["attributes"]

Expand Down
35 changes: 35 additions & 0 deletions flexmeasures_weather/cli/tests/test_get_forecasts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging

import pytest
from flexmeasures.data.models.time_series import TimedBelief

from ..commands import collect_weather_data
Expand All @@ -24,6 +25,7 @@ def test_get_weather_forecasts_to_db(
weather_station = wind_sensor.generic_asset

monkeypatch.setitem(app.config, "WEATHERAPI_KEY", "dummy")
monkeypatch.setitem(app.config, "WEATHER_PROVIDER", "OWM")
monkeypatch.setattr(weather, "call_api", mock_api_response)

runner = app.test_cli_runner()
Expand All @@ -44,6 +46,38 @@ def test_get_weather_forecasts_to_db(
assert wind_speed in [belief.event_value for belief in beliefs]


def test_get_weather_forecasts_wapi_mapping(
app, fresh_db, monkeypatch, run_as_cli, add_weather_sensors_fresh_db
):
"""
Test that WeatherAPI provider-specific field names are mapped independently.
"""
wind_sensor = add_weather_sensors_fresh_db["wind"]
fresh_db.session.flush()
wind_sensor_id = wind_sensor.id
weather_station = wind_sensor.generic_asset

monkeypatch.setitem(app.config, "WEATHERAPI_KEY", "dummy")
monkeypatch.setitem(app.config, "WEATHER_PROVIDER", "WAPI")
monkeypatch.setattr(weather, "call_api", mock_api_response)

runner = app.test_cli_runner()
result = runner.invoke(
collect_weather_data,
["--location", f"{weather_station.latitude},{weather_station.longitude}"],
)
assert "Reported task get-weather-forecasts status as True" in result.output

beliefs = (
fresh_db.session.query(TimedBelief)
.filter(TimedBelief.sensor_id == wind_sensor_id)
.all()
)
assert len(beliefs) == 2
expected_values = [pytest.approx(100 / 3.6), pytest.approx(90 / 3.6)]
assert [belief.event_value for belief in beliefs] == expected_values


def test_get_weather_forecasts_no_close_sensors(
app, db, monkeypatch, run_as_cli, add_weather_sensors_fresh_db, caplog
):
Expand All @@ -54,6 +88,7 @@ def test_get_weather_forecasts_no_close_sensors(
weather_station = add_weather_sensors_fresh_db["wind"].generic_asset

monkeypatch.setitem(app.config, "WEATHERAPI_KEY", "dummy")
monkeypatch.setitem(app.config, "WEATHER_PROVIDER", "OWM")
monkeypatch.setattr(weather, "call_api", mock_api_response)

runner = app.test_cli_runner()
Expand Down
20 changes: 15 additions & 5 deletions flexmeasures_weather/cli/tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import List
from datetime import datetime, timedelta

from flask import current_app
from flexmeasures.utils.time_utils import as_server_time, get_timezone


Expand All @@ -17,11 +17,21 @@ def mock_api_response(api_key, location):
mock_date_tz_aware = as_server_time(
datetime.fromtimestamp(mock_date.timestamp(), tz=get_timezone())
).replace(second=0, microsecond=0)

provider = str(current_app.config.get("WEATHER_PROVIDER", ""))
date_key = "dt"
temp_key = "temp"
wind_speed_key = "wind_speed"
if provider == "WAPI":
date_key = "time_epoch"
temp_key = "temp_c"
wind_speed_key = "wind_kph"

return mock_date_tz_aware, [
{"dt": mock_date.timestamp(), "temp": 40, "wind_speed": 100},
{date_key: mock_date.timestamp(), temp_key: 40, wind_speed_key: 100},
{
"dt": (mock_date + timedelta(hours=1)).timestamp(),
"temp": 42,
"wind_speed": 90,
date_key: (mock_date + timedelta(hours=1)).timestamp(),
temp_key: 42,
wind_speed_key: 90,
},
]
12 changes: 8 additions & 4 deletions flexmeasures_weather/sensor_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,32 @@
mapping = [
dict(
fm_sensor_name="temperature",
weather_sensor_name="temp",
OWM_sensor_name="temp",
WAPI_sensor_name="temp_c",
unit="°C",
event_resolution=timedelta(minutes=60),
attributes=weather_attributes,
),
dict(
fm_sensor_name="wind speed",
weather_sensor_name="wind_speed",
OWM_sensor_name="wind_speed",
WAPI_sensor_name="wind_kph",
unit="m/s",
event_resolution=timedelta(minutes=60),
attributes=weather_attributes,
),
dict(
fm_sensor_name="cloud cover",
weather_sensor_name="clouds",
OWM_sensor_name="clouds",
WAPI_sensor_name="cloud",
unit="%",
event_resolution=timedelta(minutes=60),
attributes=weather_attributes,
),
dict(
fm_sensor_name="irradiance", # in save_forecasts_to_db, we catch this name and do the actual computation to get to the irradiance
weather_sensor_name="clouds",
OWM_sensor_name="clouds",
WAPI_sensor_name="cloud",
unit="W/m²",
event_resolution=timedelta(minutes=60),
attributes=weather_attributes,
Expand Down
4 changes: 1 addition & 3 deletions flexmeasures_weather/utils/locating.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,7 @@ def find_weather_sensor_by_location(

def get_location_by_asset_id(asset_id: int) -> Tuple[float, float]:
"""Get location for forecasting by passing an asset id"""
asset = GenericAsset.query.filter(
GenericAsset.generic_asset_type_id == asset_id
).one_or_none()
asset = GenericAsset.query.filter(GenericAsset.id == asset_id).one_or_none()
if asset.generic_asset_type.name != WEATHER_STATION_TYPE_NAME:
raise Exception(
f"Asset {asset} does not seem to be a weather station we should use ― we expect an asset with type '{WEATHER_STATION_TYPE_NAME}'."
Expand Down
64 changes: 18 additions & 46 deletions flexmeasures_weather/utils/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,45 +68,7 @@ def process_weatherapi_data(
combined = first_day + second_day + third_day

relevant = combined[hour_no : hour_no + 48]
# relevant = combined

def map_weather_api_to_owm(weather_api_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Converts a single hour of WeatherAPI data to an OpenWeatherMap-style dictionary.

Args:
weather_api_data (Dict[str, Any]): A dictionary containing an hour's data from WeatherAPI.

Returns:
Dict[str, Any]: A dictionary with keys and structure similar to OpenWeatherMap's hourly forecast.
"""
game = {
"dt": weather_api_data["time_epoch"],
"temp": weather_api_data["temp_c"],
"feels_like": weather_api_data["feelslike_c"],
"pressure": weather_api_data["pressure_mb"],
"humidity": weather_api_data["humidity"],
"dew_point": weather_api_data["dewpoint_c"],
"uvi": weather_api_data["uv"],
"clouds": weather_api_data["cloud"],
"visibility": weather_api_data["vis_km"] * 1000,
"wind_speed": weather_api_data["wind_kph"] / 3.6,
"wind_deg": weather_api_data["wind_degree"],
"wind_gust": weather_api_data["gust_kph"] / 3.6,
"weather": [
{
"id": weather_api_data["condition"]["code"],
"main": weather_api_data["condition"]["text"].split()[0],
"description": weather_api_data["condition"]["text"],
"icon": weather_api_data["condition"]["icon"],
}
],
"pop": weather_api_data["chance_of_rain"] / 100,
}
return game

converted = [map_weather_api_to_owm(hour) for hour in relevant]
return converted
return relevant


def call_openweatherapi(
Expand Down Expand Up @@ -212,7 +174,7 @@ def call_api(
return call_weatherapi(api_key, location)


def save_forecasts_in_db(
def save_forecasts_in_db( # noqa: C901
api_key: str,
locations: List[Tuple[float, float]],
):
Expand All @@ -226,6 +188,11 @@ def save_forecasts_in_db(
"WEATHER_MAXIMAL_DEGREE_LOCATION_DISTANCE",
DEFAULT_MAXIMAL_DEGREE_LOCATION_DISTANCE,
)
provider = str(current_app.config.get("WEATHER_PROVIDER", ""))
Comment thread
nhoening marked this conversation as resolved.
if provider not in ["OWM", "WAPI"]:
raise Exception(
"Invalid provider name. Please set WEATHER_PROVIDER setting in config file to either OWM or WAPI, the two permissible options."
)
for location in locations:
click.echo("[FLEXMEASURES] %s, %s" % location)
weather_sensors: Dict[str, Sensor] = (
Expand All @@ -241,22 +208,23 @@ def save_forecasts_in_db(
f"[FLEXMEASURES-WEATHER] Warning: difference between this server and Weather Provider is {naturaldelta(diff_fm_owm)}"
)
click.echo(
f"[FLEXMEASURES-WEATHER] Called OpenWeatherMap API successfully at {now}."
f"[FLEXMEASURES-WEATHER] Called weather provider {provider} API successfully at {now}."
)

# loop through forecasts, including the one of current hour (horizon 0)
for fc in forecasts:
time_key = fc["dt"] if provider == "OWM" else fc["time_epoch"]
fc_datetime = as_server_time(
datetime.fromtimestamp(fc["dt"], get_timezone())
datetime.fromtimestamp(time_key, get_timezone())
)
click.echo(
f"[FLEXMEASURES-WEATHER] Processing forecast for {fc_datetime} ..."
)
data_source = get_or_create_owm_data_source()
for sensor_specs in mapping:
sensor_name = str(sensor_specs["fm_sensor_name"])
owm_response_label = sensor_specs["weather_sensor_name"]
if owm_response_label in fc:
provider_response_label = sensor_specs[f"{provider}_sensor_name"]
if provider_response_label in fc:
weather_sensor = get_weather_sensor(
sensor_specs,
location,
Expand All @@ -270,7 +238,11 @@ def save_forecasts_in_db(
if weather_sensor not in db_forecasts.keys():
db_forecasts[weather_sensor] = []

fc_value = fc[owm_response_label]
fc_value = fc[provider_response_label]

if provider_response_label == "wind_kph":
# convert wind speed from kph to m/s
fc_value = fc[provider_response_label] / 3.6

# the irradiance is not available in Provider -> we compute it ourselves
if sensor_name == "irradiance":
Expand All @@ -297,7 +269,7 @@ def save_forecasts_in_db(
else:
# we will not fail here, but issue a warning
msg = "No label '%s' in response data for time %s" % (
owm_response_label,
provider_response_label,
fc_datetime,
)
click.echo("[FLEXMEASURES-WEATHER] %s" % msg)
Expand Down
2 changes: 0 additions & 2 deletions requirements/app.in
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
flexmeasures
# remove when FlexMeasures also removes this
marshmallow>=3,<4
pvlib
# the following three are optional in pvlib, but we use them
netCDF4
Expand Down
Loading