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

first working version of a charts/power view; #39

Merged
merged 13 commits into from Feb 25, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -3,9 +3,8 @@
import pytest
import pytz
import isodate
from marshmallow import ValidationError

from flexmeasures.api.common.schemas import DurationField
from flexmeasures.api.common.schemas.times import DurationField, DurationValidationError


@pytest.mark.parametrize(
Expand Down Expand Up @@ -70,6 +69,6 @@ def test_duration_field_nominal_grounded(
)
def test_duration_field_invalid(duration_input, error_msg):
df = DurationField()
with pytest.raises(ValidationError) as ve:
with pytest.raises(DurationValidationError) as ve:
df.deserialize(duration_input, None, None)
assert error_msg in str(ve)
@@ -1,11 +1,17 @@
from typing import Union, Optional
from datetime import datetime, timedelta

from marshmallow import fields, ValidationError
from marshmallow import fields
import isodate
from isodate.isoerror import ISO8601Error
import pandas as pd

from flexmeasures.api.common.utils.args_parsing import FMValidationError


class DurationValidationError(FMValidationError):
status = "INVALID_PERIOD" # USEF error status


class DurationField(fields.Str):
"""Field that deserializes to a ISO8601 Duration
Expand All @@ -23,7 +29,7 @@ def _deserialize(
try:
return isodate.parse_duration(value)
except ISO8601Error as iso_err:
raise ValidationError(
raise DurationValidationError(
f"Cannot parse {value} as ISO8601 duration: {iso_err}"
)

Expand Down
49 changes: 30 additions & 19 deletions flexmeasures/api/common/utils/args_parsing.py
Expand Up @@ -4,37 +4,48 @@
from webargs.flaskparser import parser

"""
Utils for argument parsing (we use webargs)
Utils for argument parsing (we use webargs),
including error handling.
"""


class FMValidationError(Exception):
""" Custom validation error class """
@parser.error_handler
def handle_error(error, req, schema, *, error_status_code, error_headers):
"""Replacing webargs's error parser, so we can throw custom Exceptions."""
if error.__class__ == ValidationError:
# re-package all marshmallow's validation errors as our own kind (see below)
raise FMValidationError(message=error.messages)
raise error


class FMValidationError(ValidationError):
"""
Custom validation error class.
It differs from the classic validation error by having two
attributes, according to the USEF 2015 reference implementation.
Subclasses of this error might adjust the `status` attribute accordingly.
"""

def __init__(self, messages):
self.result = "Rejected"
self.status = "UNPROCESSABLE_ENTITY"
self.messages = messages
result = "Rejected"
status = "UNPROCESSABLE_ENTITY"


def validation_error_handler(error):
"""Handles errors during parsing. Aborts the current HTTP request and
responds with a 422 error.
def validation_error_handler(error: FMValidationError):
"""Handles errors during parsing.
Aborts the current HTTP request and responds with a 422 error.
FMValidationError attributes "result" and "status" are packaged in the response.
"""
status_code = 422
response = jsonify(error.messages)
response_data = dict(message=error.messages)
if hasattr(error, "result"):
response_data["result"] = error.result
if hasattr(error, "status"):
response_data["status"] = error.status
response = jsonify(response_data)
response.status_code = status_code
return response


@parser.error_handler
def handle_error(error, req, schema, *, error_status_code, error_headers):
"""Replacing webargs's error parser, so we can throw custom Exceptions."""
if error.__class__ == ValidationError:
raise FMValidationError(messages=error.messages)
raise error


@parser.location_loader("args_and_json")
def load_data(request, schema):
"""
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/common/utils/validators.py
Expand Up @@ -16,7 +16,7 @@

from webargs.flaskparser import parser

from flexmeasures.api.common.schemas import DurationField
from flexmeasures.api.common.schemas.times import DurationField
from flexmeasures.api.common.responses import ( # noqa: F401
required_info_missing,
invalid_horizon,
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/v1_2/tests/test_api_v1_2.py
Expand Up @@ -69,7 +69,7 @@ def test_get_device_message_mistyped_duration(client):
assert get_device_message_response.status_code == 422
assert (
"Cannot parse PTT6H as ISO8601 duration"
in get_device_message_response.json["args_and_json"]["duration"][0]
in get_device_message_response.json["message"]["args_and_json"]["duration"][0]
)


Expand Down
12 changes: 6 additions & 6 deletions flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py
Expand Up @@ -166,7 +166,7 @@ def test_post_an_asset_with_nonexisting_field(client):
headers={"content-type": "application/json", "Authorization": auth_token},
)
assert asset_creation.status_code == 422
assert asset_creation.json["json"]["nnname"][0] == "Unknown field."
assert asset_creation.json["message"]["json"]["nnname"][0] == "Unknown field."


def test_posting_multiple_assets(client):
Expand All @@ -183,7 +183,7 @@ def test_posting_multiple_assets(client):
)
print(f"Response: {asset_creation.json}")
assert asset_creation.status_code == 422
assert asset_creation.json["json"]["_schema"][0] == "Invalid input type."
assert asset_creation.json["message"]["json"]["_schema"][0] == "Invalid input type."


def test_post_an_asset(client):
Expand Down Expand Up @@ -234,16 +234,16 @@ def test_post_an_asset_with_invalid_data(client, db):

assert (
"Must be greater than or equal to 0"
in post_asset_response.json["json"]["capacity_in_mw"][0]
in post_asset_response.json["message"]["json"]["capacity_in_mw"][0]
)
assert (
"greater than or equal to -180 and less than or equal to 180"
in post_asset_response.json["json"]["longitude"][0]
in post_asset_response.json["message"]["json"]["longitude"][0]
)
assert "required field" in post_asset_response.json["json"]["unit"][0]
assert "required field" in post_asset_response.json["message"]["json"]["unit"][0]
assert (
"must be equal or higher than the minimum soc"
in post_asset_response.json["json"]["max_soc_in_mwh"]
in post_asset_response.json["message"]["json"]["max_soc_in_mwh"]
)

assert Asset.query.filter_by(owner_id=prosumer.id).count() == num_assets_before
Expand Down
2 changes: 2 additions & 0 deletions flexmeasures/app.py
Expand Up @@ -6,6 +6,7 @@
from flask_mail import Mail
from flask_sslify import SSLify
from flask_json import FlaskJSON
from flask_cors import CORS

from redis import Redis
from rq import Queue
Expand Down Expand Up @@ -43,6 +44,7 @@ def create(env=None) -> Flask:

app.mail = Mail(app)
FlaskJSON(app)
cors = CORS(app)

# configure Redis (for redis queue)
if app.testing:
Expand Down
6 changes: 4 additions & 2 deletions flexmeasures/ui/__init__.py
Expand Up @@ -58,8 +58,10 @@ def favicon():
add_jinja_filters(app)
add_jinja_variables(app)

# re-register api blueprint so it'll register the chart views (see views.charts)
app.register_blueprint(flexmeasures_api_v2_0, first_registration=False)
# re-register api blueprint so it'll register the chart views (the ones in views.charts)
app.register_blueprint(
flexmeasures_api_v2_0, url_prefix="/api/v2_0", first_registration=False
)


def register_rq_dashboard(app):
Expand Down
3 changes: 2 additions & 1 deletion flexmeasures/ui/utils/plotting_utils.py
Expand Up @@ -251,6 +251,7 @@ def create_graph( # noqa: C901
show_y_floats: bool = False,
non_negative_only: bool = False,
tools: List[str] = None,
sizing_mode="scale_width",
) -> Figure:
"""
Create a Bokeh graph. As of now, assumes x data is datetimes and y data is numeric. The former is not set in stone.
Expand Down Expand Up @@ -310,7 +311,7 @@ def create_graph( # noqa: C901
tools=tools,
h_symmetry=False,
v_symmetry=False,
sizing_mode="scale_width",
sizing_mode=sizing_mode,
outline_line_color="#666666",
)

Expand Down
10 changes: 9 additions & 1 deletion flexmeasures/ui/views/analytics.py
Expand Up @@ -528,6 +528,7 @@ def make_power_figure(
show_consumption_as_positive: bool,
shared_x_range: Range1d,
tools: List[str] = None,
sizing_mode="scale_width",
) -> Figure:
"""Make a bokeh figure for power consumption or generation"""
if show_consumption_as_positive:
Expand All @@ -536,7 +537,7 @@ def make_power_figure(
title = "Electricity production from %s" % resource_display_name

resolution_str = "?"
if data.index.freq is not None:
if hasattr(data.index, "freq") and data.index.freq is not None:
resolution_str = time_utils.freq_label_to_human_readable_label(
data.index.freqstr
)
Expand All @@ -560,6 +561,7 @@ def make_power_figure(
y_label="Power (in MW)",
show_y_floats=True,
tools=tools,
sizing_mode=sizing_mode,
)


Expand All @@ -569,6 +571,7 @@ def make_prices_figure(
shared_x_range: Range1d,
selected_market: Market,
tools: List[str] = None,
sizing_mode="scale_width",
) -> Figure:
"""Make a bokeh figure for price data"""
return create_graph(
Expand All @@ -583,6 +586,7 @@ def make_prices_figure(
y_label="Price (in %s)" % selected_market.unit,
show_y_floats=True,
tools=tools,
sizing_mode=sizing_mode,
)


Expand All @@ -593,6 +597,7 @@ def make_weather_figure(
shared_x_range: Range1d,
weather_sensor: WeatherSensor,
tools: List[str] = None,
sizing_mode="scale_width",
) -> Figure:
"""Make a bokeh figure for weather data"""
# Todo: plot average temperature/total_radiation/wind_speed for asset groups, and update title accordingly
Expand Down Expand Up @@ -623,6 +628,7 @@ def make_weather_figure(
legend_location="top_right",
show_y_floats=True,
tools=tools,
sizing_mode=sizing_mode,
)


Expand All @@ -634,6 +640,7 @@ def make_revenues_costs_figure(
shared_x_range: Range1d,
selected_market: Market,
tools: List[str] = None,
sizing_mode="scale_width",
) -> Figure:
"""Make a bokeh figure for revenues / costs data"""
if show_consumption_as_positive:
Expand All @@ -655,6 +662,7 @@ def make_revenues_costs_figure(
y_label="%s (in %s)" % (rev_cost_str, selected_market.unit[:3]),
show_y_floats=True,
tools=tools,
sizing_mode=sizing_mode,
)


Expand Down
37 changes: 20 additions & 17 deletions flexmeasures/ui/views/charts.py
@@ -1,13 +1,12 @@
from flask_security import roles_accepted
from flask_json import as_json
from bokeh.embed import json_item
from marshmallow import Schema, fields
from webargs.flaskparser import use_args
from flask_cors import cross_origin
from flask_json import as_json

from flexmeasures.api.v2_0 import flexmeasures_api as flexmeasures_api_v2_0
from flexmeasures.api.v2_0.routes import v2_0_service_listing
from flexmeasures.api.common.schemas import DurationField
from flexmeasures.api.common.schemas.times import DurationField
from flexmeasures.data.queries.analytics import get_power_data
from flexmeasures.ui.views.analytics import make_power_figure

Expand All @@ -26,7 +25,7 @@

v2_0_service_listing["services"].append(
nhoening marked this conversation as resolved.
Show resolved Hide resolved
{
"name": "POST /charts/power",
"name": "GET /charts/power",
"access": ["admin", "Prosumer"],
"description": "Get a Bokeh chart for power data to embed in web pages.",
},
Expand All @@ -43,13 +42,15 @@ class ChartRequestSchema(Schema):
end_time = fields.DateTime(required=True)
resolution = DurationField(required=True)
show_consumption_as_positive = fields.Bool(missing=True)
forecast_horizon = DurationField(missing="PT6H") # TODO: HorizonField
show_individual_traces_for = fields.Str(
missing="none", validate=lambda x: x in ("none", "schedules", "power")
)
forecast_horizon = DurationField(missing="PT6H") # TODO: HorizonField?
nhoening marked this conversation as resolved.
Show resolved Hide resolved


@flexmeasures_api_v2_0.route("/charts/power", methods=["POST"])
@cross_origin() # origins=["localhost"])
@flexmeasures_api_v2_0.route("/charts/power", methods=["GET"])
@roles_accepted("admin", "Prosumer")
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
@use_args(ChartRequestSchema())
@use_args(ChartRequestSchema(), location="querystring")
@as_json
def get_power_chart(chart_request):
"""API endpoint to get a chart for power data which can be embedded in web pages.
Expand Down Expand Up @@ -85,20 +86,21 @@ def get_power_chart(chart_request):
.. sourcecode:: javascript

<script>
fetch('http://localhost:5000/charts/power',
fetch('http://localhost:5000/api/v2_0/charts/power?' + urlData.toString(),
{
method: "POST",
headers:{
"Content-Type": "application/json",
"Authorization": "<users auth token>"
},
body: JSON.stringify(data),
method: "GET",
mode: "cors",
headers:
{
"Content-Type": "application/json",
"Authorization": "<users auth token>"
},
})
.then(function(response) { return response.json(); })
.then(function(item) { Bokeh.embed.embed_item(item, "<ID of the div >"); });
</script>

where `data` contains the chart request parameters (see above).
where `urlData` is a `URLSearchData` object and contains the chart request parameters (see above).

:reqheader Authorization: The authentication token
:reqheader Content-Type: application/json
Expand All @@ -112,7 +114,7 @@ def get_power_chart(chart_request):
data = get_power_data(
resource=chart_request["resource"],
show_consumption_as_positive=chart_request["show_consumption_as_positive"],
showing_individual_traces_for="none", # TODO: parameterise
showing_individual_traces_for=chart_request["show_individual_traces_for"],
metrics={}, # will be stored here, we don't need them for now
query_window=(chart_request["start_time"], chart_request["end_time"]),
resolution=chart_request["resolution"],
Expand All @@ -125,5 +127,6 @@ def get_power_chart(chart_request):
schedule_data=data[2],
show_consumption_as_positive=chart_request["show_consumption_as_positive"],
shared_x_range=None,
sizing_mode="scale_both",
)
return json_item(figure)