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 2 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
29 changes: 29 additions & 0 deletions flexmeasures/api/common/schemas.py
@@ -0,0 +1,29 @@
from typing import Union

from datetime import timedelta

from marshmallow import fields, ValidationError
import isodate
from isodate.isoerror import ISO8601Error


class PeriodField(fields.Str):
"""Field that serializes to a Period and deserializes to a string."""
Flix6x marked this conversation as resolved.
Show resolved Hide resolved

def _deserialize(
self, value, attr, obj, **kwargs
) -> Union[timedelta, isodate.Duration]:
return isodate.parse_duration(value)

def _vaidate(value):
"""Validate a marshmallow ISO8601 duration field,
throw marshmallow validation error if it cannot be parsed."""
try:
isodate.parse_duration(value)
except ISO8601Error as iso_err:
raise ValidationError(
f"Cannot parse {value} as ISO8601 duration: {iso_err}"
)

def _serialize(self, value, attr, data, **kwargs):
return isodate.strftime(value, "%P")
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
15 changes: 2 additions & 13 deletions flexmeasures/api/common/utils/validators.py
Expand Up @@ -14,9 +14,9 @@
from flask_security import current_user
import marshmallow

from webargs import fields
from webargs.flaskparser import parser

from flexmeasures.api.common.schemas import PeriodField
from flexmeasures.api.common.responses import ( # noqa: F401
required_info_missing,
invalid_horizon,
Expand Down Expand Up @@ -147,17 +147,6 @@ def parse_duration(
return None


def validate_duration_field(duration_str):
"""Validate a marshmallow ISO8601 duration field,
throw marshmallow validation error if it cannot be parsed."""
try:
isodate.parse_duration(duration_str)
except ISO8601Error as iso_err:
raise marshmallow.ValidationError(
f"Cannot parse {duration_str} as ISO8601 duration: {iso_err}"
)


def parse_isodate_str(start: str) -> Union[datetime, None]:
"""
Validates whether the string 'start' is a valid ISO 8601 datetime.
Expand Down Expand Up @@ -203,7 +192,7 @@ def wrapper(fn):
@as_json
def decorated_service(*args, **kwargs):
duration_arg = parser.parse(
{"duration": fields.Str(validate=validate_duration_field)},
{"duration": PeriodField()},
request,
location="args_and_json",
unknown=marshmallow.EXCLUDE,
Expand Down
12 changes: 6 additions & 6 deletions flexmeasures/app.py
Expand Up @@ -80,18 +80,18 @@ def create(env=None) -> Flask:

register_db_at(app)

# Register the UI

from flexmeasures.ui import register_at as register_ui_at

register_ui_at(app)

# Register the API

from flexmeasures.api import register_at as register_api_at

register_api_at(app)

# Register the UI

from flexmeasures.ui import register_at as register_ui_at

register_ui_at(app)

# Profile endpoints (if needed, e.g. during development)
@app.before_request
def before_request():
Expand Down
4 changes: 4 additions & 0 deletions flexmeasures/ui/__init__.py
Expand Up @@ -15,6 +15,7 @@
localized_datetime_str,
naturalized_datetime_str,
)
from flexmeasures.api.v2_0 import flexmeasures_api as flexmeasures_api_v2_0

# The ui blueprint. It is registered with the Flask app (see app.py)
flexmeasures_ui = Blueprint(
Expand Down Expand Up @@ -57,6 +58,9 @@ def favicon():
add_jinja_filters(app)
add_jinja_variables(app)

# re-register api blueprint so it'll register the chart views (see views.charts)
nhoening marked this conversation as resolved.
Show resolved Hide resolved
app.register_blueprint(flexmeasures_api_v2_0, first_registration=False)


def register_rq_dashboard(app):
app.config.update(
Expand Down
2 changes: 2 additions & 0 deletions flexmeasures/ui/views/__init__.py
Expand Up @@ -11,6 +11,8 @@

from flexmeasures.ui.views.account import account_view # noqa: F401 # noqa: F401

from flexmeasures.ui.views.charts import get_power_chart # noqa: F401


@flexmeasures_ui.route("/docs")
def docs_view():
Expand Down
13 changes: 11 additions & 2 deletions flexmeasures/ui/views/analytics.py
Expand Up @@ -535,6 +535,16 @@ def make_power_figure(
else:
title = "Electricity production from %s" % resource_display_name

resolution_str = "?"
if data.index.freq is not None:
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
resolution_str = time_utils.freq_label_to_human_readable_label(
data.index.freqstr
)
elif "resolution" in session:
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
resolution_str = time_utils.freq_label_to_human_readable_label(
session["resolution"]
)

return create_graph(
data,
unit="MW",
Expand All @@ -546,8 +556,7 @@ def make_power_figure(
schedules=schedule_data,
title=title,
x_range=shared_x_range,
x_label="Time (resolution of %s)"
% time_utils.freq_label_to_human_readable_label(session["resolution"]),
x_label="Time (resolution of %s)" % resolution_str,
y_label="Power (in MW)",
show_y_floats=True,
tools=tools,
Expand Down
129 changes: 129 additions & 0 deletions flexmeasures/ui/views/charts.py
@@ -0,0 +1,129 @@
from flask_security import roles_accepted
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 PeriodField
from flexmeasures.data.queries.analytics import get_power_data
from flexmeasures.ui.views.analytics import make_power_figure


"""
An endpoint to get a power chart.

This will grow to become code for more charts eventually.
The plan is to separate charts specs from the actual data later,
and to switch to Altair.

For now, we'll keep this endpoint here, with route and implementation in the same file.
When we move forward, we'll review the architecture.
"""


v2_0_service_listing["services"].append(
nhoening marked this conversation as resolved.
Show resolved Hide resolved
{
"name": "POST /charts/power",
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
"access": ["admin", "Prosumer"],
"description": "Get a Bokeh chart for power data to embed in web pages.",
},
)


class ChartRequestSchema(Schema):
"""
This schema describes the request for a chart.
"""

resource = fields.Str(required=True)
start_time = fields.DateTime(required=True)
end_time = fields.DateTime(required=True)
resolution = PeriodField(required=True)
show_consumption_as_positive = fields.Bool(missing=True)
forecast_horizon = PeriodField(missing="PT6H")


@flexmeasures_api_v2_0.route("/charts/power", methods=["POST"])
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
@cross_origin() # origins=["localhost"])
nhoening marked this conversation as resolved.
Show resolved Hide resolved
@roles_accepted("admin", "Prosumer")
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
@use_args(ChartRequestSchema())
@as_json
def get_power_chart(chart_request):
"""API endpoint to get a chart for power data which can be embedded in web pages.

.. :quickref: Chart; Get a power chart

This endpoint returns a Bokeh chart with power data which can be embedded in a website.
It includes forecasts and even schedules, if available.

**Example request**

An example of a chart request:

.. sourcecode:: json

{
"resource": ""my-battery,
"start_time": "2020-02-20:10:00:00UTC",
"end_time": "2020-02-20:11:00:00UTC",
"resolution": "PT15M",
"consumption_as_positive": true
"resolution": "PT6H",
}

On your webpage, you need to include the Bokeh libraries, e.g.:

<script src="https://cdn.pydata.org/bokeh/release/bokeh-1.0.4.min.js"></script>

(The version needs to match the version used by the FlexMeasures server, see requirements/app.txt)

Then you can call this endpoint and include the result like this:

.. sourcecode:: javascript

<script>
fetch('http://localhost:5000/charts/power',
{
method: "POST",
headers:{
"Content-Type": "application/json",
"Authorization": "<users auth token>"
},
body: JSON.stringify(data),
})
.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).

:reqheader Authorization: The authentication token
:reqheader Content-Type: application/json
:resheader Content-Type: application/json
:status 200: PROCESSED
:status 400: INVALID_REQUEST
:status 401: UNAUTHORIZED
:status 403: INVALID_SENDER
:status 422: UNPROCESSABLE_ENTITY
"""
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
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"],
forecast_horizon=chart_request["forecast_horizon"],
)
figure = make_power_figure(
resource_display_name=chart_request["resource"],
data=data[0],
forecast_data=data[1],
schedule_data=data[2],
show_consumption_as_positive=chart_request["show_consumption_as_positive"],
shared_x_range=None,
)
return json_item(figure)
3 changes: 2 additions & 1 deletion requirements/app.in
Expand Up @@ -43,7 +43,8 @@ Flask-Mail
Flask-Security-Too
Flask-Classful
Flask-Marshmallow
Flask-Cors
marshmallow-sqlalchemy>=0.23.1
webargs
# flask should be after all the flask plugins, because setup might find they ARE flask
flask>=1.0
flask>=1.0