Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Working first version #1

Merged
merged 26 commits into from
Feb 28, 2022
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
67e0aa5
raw copy of fm code
nhoening Feb 1, 2022
504ce8f
first tests for registering sensor working, as well as mypy
nhoening Feb 2, 2022
4952159
make sensor registration work; add supported sensor map, validation a…
nhoening Feb 5, 2022
16648b5
only identify sensors by name (hardcode how we treat it in FM), also …
nhoening Feb 6, 2022
e742479
ignore /dist and /build with mypy
nhoening Feb 7, 2022
493716d
improve error message for existing sensor
nhoening Feb 7, 2022
7243ff0
make getting forecasts work
nhoening Feb 10, 2022
01bcb61
add test for getting forecasts
nhoening Feb 10, 2022
7064df6
make all tests work again; re-use weather sensor creation fixture fro…
nhoening Feb 11, 2022
5cacf13
create dir for JSON files if it does not exist yet
nhoening Feb 11, 2022
cb30917
update Readme
nhoening Feb 11, 2022
a5eb423
update the lint and test workflow specs
nhoening Feb 15, 2022
6098bcf
first batch of small refactorings and typo fixes from review
nhoening Feb 15, 2022
269ea64
give sensor specs a more prominent place and add sensor resolution
nhoening Feb 15, 2022
1c314ce
use server time as TimedBelief.belief_time, also use [FLEXMEASURES-OW…
nhoening Feb 15, 2022
271bf36
use an extra data source for derived irradiation computed values
nhoening Feb 15, 2022
db25ffb
add a test for getting forecasts for a location with no weather stati…
nhoening Feb 16, 2022
267efc9
fit import to FM 0.9
nhoening Feb 16, 2022
7860a39
rename sensors, check resolution of found weather sensors, tighter gr…
nhoening Feb 16, 2022
79abd7c
do not check scripts
nhoening Feb 16, 2022
35469bf
remove irradiance computation and pvlib dependency
nhoening Feb 16, 2022
814ab67
depend on 0.9.0 dev release
nhoening Feb 16, 2022
d53d01e
freeze dev after test dependenices; upgrade dependencies
nhoening Feb 16, 2022
a412097
test adding two weather stations
nhoening Feb 24, 2022
be3fc41
refactor sensor specs and how they are applied in the CLI command
nhoening Feb 24, 2022
c8dec21
smaller comments from review
nhoening Feb 24, 2022
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,4 @@ dmypy.json
.ipynb_checkpoints/
notebooks/.ipynb_checkpoints/

flexmeasures.log
38 changes: 32 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
# flexmeasures-openweathermap - a plugin for FlexMeasures

# flexmeasures-openweathermap - a plugin for FlexMeasures to integrate OpenWeatherMap data

## Usage

To register a new weather sensor:

`flexmeasures owm register-weather-sensor --name wind-speed --latitude 30 --longitude 40`
nhoening marked this conversation as resolved.
Show resolved Hide resolved

Currently supported: wind_speed, temperature & radiation.
nhoening marked this conversation as resolved.
Show resolved Hide resolved

Notes about weather sensor setup:

- Weather sensors are public. They are accessible by all accounts on a FlexMeasures server. TODO: maybe limit this to a list of account roles.
- The resolution is one hour. OWM also supports minutely data within the upcoming hour(s), but that is not supported here.

To collect weather forecasts:

`flexmeasures owm get-weather-forecasts --location 30,40`

This saves forecasts for your registered sensors in the database.

Use the `--help`` option for more options, e.g. for specifying two locations and ask for a few weather stations to cover the region between them.
nhoening marked this conversation as resolved.
Show resolved Hide resolved

An alternative usage is to save raw results in JSON files (for later processing), like this:

`flexmeasures owm get-weather-forecasts --location 30,40 --store-as-json-files --region somewhere`

This saves the complete response from OpenWeatherMap in a local folder (i.e. no sensor registration needed, this is a direct way to use OWM, without FlexMeasures integration). `region` will become a subfolder.
nhoening marked this conversation as resolved.
Show resolved Hide resolved

Finally, note that currently 1000 free calls per day can be made to the OpenWeatherMap API,
so you can make a call every 15 minutes for up to 10 assets or every hour for up to 40 assets (or get a paid account).
nhoening marked this conversation as resolved.
Show resolved Hide resolved


## Installation

1. Add "/path/to/flexmeasures-openweathermap/flexmeasures_openweathermap" to your FlexMeasures (>v0.7.0dev8) config file,
using the FLEXMEASURES_PLUGINS setting (a list).
Alternatively, if you installed this plugin as a package, then "flexmeasures_openweathermap" suffices.
Add "/path/to/flexmeasures-openweathermap/flexmeasures_openweathermap" to your FlexMeasures (>v0.7.0dev8) config file,
using the FLEXMEASURES_PLUGINS setting (a list).

2.
Alternatively, if you installed this plugin as a package (e.g. via `python setup.py install`, `pip install -e` or `pip install flexmeasures_openweathermap` after this project is on Pypi), then "flexmeasures_openweathermap" suffices.


## Development
Expand Down
42 changes: 35 additions & 7 deletions flexmeasures_openweathermap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from flask import Blueprint

from .utils import ensure_bp_routes_are_loaded_fresh
from .utils.blueprinting import ensure_bp_routes_are_loaded_fresh

# Overwriting version (if possible) from the package metadata
# ― if this plugin has been installed as a package.
Expand All @@ -24,13 +24,41 @@
# package is not installed
pass


DEFAULT_FILE_PATH_LOCATION = "weather-forecasts"
DEFAULT_DATA_SOURCE_NAME = "OpenWeatherMap"
WEATHER_STATION_TYPE_NAME = "Weather station"
DEFAULT_WEATHER_STATION_NAME = "Weather station (created by FM-OWM)"
nhoening marked this conversation as resolved.
Show resolved Hide resolved

__version__ = "0.1"
__settings__ = {
"OPENWEATHERMAP_API_KEY": dict(
description="You can generate this token after you made an account at OpenWeatherMap.",
level="error",
),
"OPENWEATHERMAP_DATA_SOURCE_NAME": dict(
description="Name of the data source for OWM data.",
default="OpenWeatherMap",
level="debug",
),
"FILE_PATH_LOCATION": dict(
description="Location of JSON files (if you store weather data in this form). Absolute path.",
level="debug",
),
"DATA_SOURCE_NAME": dict(
description=f"Name of the data source, defaults to '{DEFAULT_DATA_SOURCE_NAME}'",
level="debug",
),
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"WEATHER_STATION_NAME": dict(
description=f"Name of the weather station asset, defaults to '{DEFAULT_WEATHER_STATION_NAME}'",
level="debug",
),
}

# CLI
flexmeasures_openweathermap_cli_bp: Blueprint = Blueprint(
"flexmeasures-openweathermap CLI",
__name__,
cli_group="flexmeasures-openweathermap"
flexmeasures_openweathermap_bp: Blueprint = Blueprint(
"flexmeasures-openweathermap CLI", __name__, cli_group="owm"
)
flexmeasures_openweathermap_cli_bp.cli.help = "flexmeasures-openweathermap CLI commands"
flexmeasures_openweathermap_bp.cli.help = "flexmeasures-openweathermap CLI commands"
ensure_bp_routes_are_loaded_fresh("cli.commands")
from flexmeasures_openweathermap.cli import commands # noqa: E402,F401

141 changes: 135 additions & 6 deletions flexmeasures_openweathermap/cli/commands.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,146 @@
from datetime import timedelta
from flask import current_app

from flask.cli import with_appcontext
import click
from flexmeasures.data.models.time_series import Sensor

from flexmeasures.data.transactional import task_with_status_report
from flexmeasures.data.config import db

from .. import flexmeasures_openweathermap_bp
from .schemas.weather_sensor import WeatherSensorSchema
from ..utils.modeling import get_weather_station, get_data_source
from ..utils.locating import get_locations
from ..utils.filing import make_file_path
from ..utils.owm import (
save_forecasts_in_db,
save_forecasts_as_json,
owm_to_sensor_map,
get_supported_sensor_spec,
)


from .. import flexmeasures_openweathermap_cli_bp
"""
TODO: allow to also pass an asset ID for the weather station (instead of location) to both commands?
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""

supported_sensors_list = ", ".join([str(o["name"]) for o in owm_to_sensor_map.values()])

@flexmeasures_openweathermap_cli_bp.cli.command("hello-world")

@flexmeasures_openweathermap_bp.cli.command("register-weather-sensor")
@with_appcontext
@click.option(
"--name",
required=True,
help=f"Name of the sensor. Has to be from the supported list ({supported_sensors_list})",
)
# @click.option("--generic-asset-id", required=False, help="The asset id of the weather station (you can also give its location).")
@click.option(
"--latitude",
required=True,
type=float,
help="Latitude of where you want to measure.",
)
@click.option(
"--longitude",
required=True,
type=float,
help="Longitude of where you want to measure.",
)
@click.option(
"--timezone",
default="UTC",
help="The timezone of the sensor data as string, e.g. 'UTC' (default) or 'Europe/Amsterdam'",
)
def add_weather_sensor(**args):
"""
Add a weather sensor.
This will first create a weather station asset if none exists at the location yet.

"""
errors = WeatherSensorSchema().validate(args)
if errors:
click.echo(
f"Please correct the following errors:\n{errors}.\n Use the --help flag to learn more."
)
raise click.Abort

weather_station = get_weather_station(args["latitude"], args["longitude"])
args["generic_asset"] = weather_station
del args["latitude"]
del args["longitude"]

args["event_resolution"] = timedelta(minutes=60) # OWM delivers hourly data
nhoening marked this conversation as resolved.
Show resolved Hide resolved

fm_sensor_specs = get_supported_sensor_spec(args["name"])
args["unit"] = fm_sensor_specs["unit"]
sensor = Sensor(**args)
sensor.attributes = fm_sensor_specs["seasonality"]

db.session.add(sensor)
db.session.commit()
click.echo(
f"Successfully created weather sensor with ID {sensor.id}, at weather station with ID {weather_station.id}"
)
click.echo(
f"You can access this sensor at its entity address {sensor.entity_address}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To discuss.

)


@flexmeasures_openweathermap_bp.cli.command("get-weather-forecasts")
@with_appcontext
@task_with_status_report
def hello_world(name: str):
print(f"Hello, {name}!")
current_app.logger.info(f"'Hello, {name}!' printed")
@click.option(
"--location",
type=str,
required=True,
help='Measurement location(s). "latitude,longitude" or "top-left-latitude,top-left-longitude:'
'bottom-right-latitude,bottom-right-longitude." The first format defines one location to measure.'
" The second format defines a region of interest with several (>=4) locations"
' (see also the "method" and "num_cells" parameters for details on how to use this feature).',
)
@click.option(
"--store-in-db/--store-as-json-files",
default=True,
help="Store forecasts in the database, or simply save as json files (defaults to database).",
)
@click.option(
"--num_cells",
type=int,
default=1,
help="Number of cells on the grid. Only used if a region of interest has been mapped in the location parameter. Defaults to 1.",
)
@click.option(
"--method",
default="hex",
type=click.Choice(["hex", "square"]),
help="Grid creation method. Only used if a region of interest has been mapped in the location parameter.",
)
@click.option(
"--region",
type=str,
default="",
help="Name of the region (will create sub-folder if you store json files).",
)
@task_with_status_report("get-openweathermap-forecasts")
def collect_weather_data(location, store_in_db, num_cells, method, region):
"""
Collect weather forecasts from the OpenWeatherMap API.
This will be done for one or more locations, for which we first identify relevant weather stations.

This function can get weather data for one location or for several locations within
a geometrical grid (See the --location parameter).
"""

api_key = str(current_app.config.get("OPENWEATHERMAP_API_KEY"))
if api_key is None:
raise Exception("Setting OPENWEATHERMAP_API_KEY not available.")
nhoening marked this conversation as resolved.
Show resolved Hide resolved
locations = get_locations(location, num_cells, method)

# Save the results
if store_in_db:
save_forecasts_in_db(api_key, locations, data_source=get_data_source())
else:
save_forecasts_as_json(
api_key, locations, data_path=make_file_path(current_app, region)
)
Empty file.
58 changes: 58 additions & 0 deletions flexmeasures_openweathermap/cli/schemas/weather_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from marshmallow import (
Schema,
validates,
validates_schema,
ValidationError,
fields,
validate,
)

import pytz
from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data import db

from ...utils.modeling import get_weather_station
from ...utils.owm import get_supported_sensor_spec, get_supported_sensors_str


class WeatherSensorSchema(Schema):
"""
Schema for the weather sensor registration.
Based on flexmeasures.Sensor, plus some attributes for the weather station asset.
"""

name = fields.Str(required=True)
timezone = fields.Str()
latitude = fields.Float(required=True, validate=validate.Range(min=-90, max=90))
longitude = fields.Float(required=True, validate=validate.Range(min=-180, max=180))

@validates("name")
def validate_name_is_supported(self, name: str):
if get_supported_sensor_spec(name):
return
raise ValidationError(
f"Weather sensors with name '{name}' are not supported by flexmeasures-openweathermap. For now, the following is supported: [{get_supported_sensors_str()}]"
)

@validates_schema(skip_on_field_errors=False)
def validate_name_is_unique_in_weather_station(self, data, **kwargs):
if "name" not in data or "latitude" not in data or "longitude" not in data:
return # That's a different validation problem
weather_station = get_weather_station(data["latitude"], data["longitude"])
nhoening marked this conversation as resolved.
Show resolved Hide resolved
if weather_station.id is None:
db.session.flush()
nhoening marked this conversation as resolved.
Show resolved Hide resolved
sensor = Sensor.query.filter(
Sensor.name == data["name"].lower(),
Sensor.generic_asset_id == weather_station.id,
).one_or_none()
if sensor:
raise ValidationError(
f"A '{data['name']}' - weather sensor already exists at this weather station (with ID {weather_station.id}))."
nhoening marked this conversation as resolved.
Show resolved Hide resolved
)

@validates("timezone")
def validate_timezone(self, timezone: str):
try:
pytz.timezone(timezone)
except pytz.UnknownTimeZoneError:
raise ValidationError(f"Timezone {timezone} is unknown!")
4 changes: 4 additions & 0 deletions flexmeasures_openweathermap/cli/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from flexmeasures.conftest import ( # noqa: F401
add_weather_sensors_fresh_db,
setup_generic_asset_types_fresh_db,
)
nhoening marked this conversation as resolved.
Show resolved Hide resolved
11 changes: 0 additions & 11 deletions flexmeasures_openweathermap/cli/tests/test_cli.py

This file was deleted.

53 changes: 53 additions & 0 deletions flexmeasures_openweathermap/cli/tests/test_get_forecasts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from datetime import datetime, timedelta

from flexmeasures.data.models.time_series import TimedBelief
from flexmeasures.utils.time_utils import as_server_time, get_timezone

from ..commands import collect_weather_data
from ...utils import owm


"""
Useful resource: https://flask.palletsprojects.com/en/2.0.x/testing/#testing-cli-commands
"""

sensor_params = {"name": "wind_speed", "latitude": 30, "longitude": 40}
nhoening marked this conversation as resolved.
Show resolved Hide resolved


def test_get_weather_forecasts_to_db(
app, fresh_db, monkeypatch, add_weather_sensors_fresh_db
):
""" Test if we can process forecast and save them to the database."""
fresh_db.session.flush()
wind_sensor_id = add_weather_sensors_fresh_db["wind"].id

def mock_owm_response(api_key, location):
mock_date = datetime.now()
mock_date_tz_aware = as_server_time(
datetime.fromtimestamp(mock_date.timestamp(), tz=get_timezone())
).replace(second=0, microsecond=0)
return mock_date_tz_aware, [
{"dt": mock_date.timestamp(), "temp": 40, "wind_speed": 100},
{
"dt": (mock_date + timedelta(hours=1)).timestamp(),
"temp": 42,
"wind_speed": 90,
},
]

monkeypatch.setenv("OPENWEATHERMAP_API_KEY", "dummy")
monkeypatch.setattr(owm, "call_openweatherapi", mock_owm_response)

runner = app.test_cli_runner()
result = runner.invoke(collect_weather_data, ["--location", "33.484,126"])
nhoening marked this conversation as resolved.
Show resolved Hide resolved
print(result.output)
assert "Reported task get-openweathermap-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
for wind_speed in (100, 90):
assert wind_speed in [belief.event_value for belief in beliefs]
Loading