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

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


class DurationField(fields.Str):
"""Field that deserializes to a ISO8601 Duration
and serializes back to a string."""

def _deserialize(
self, value, attr, obj, **kwargs
) -> Union[timedelta, isodate.Duration]:
"""
Use the isodate library to turn an ISO8601 string into a timedelta.
For some non-obvious cases, it will become an isodate.Duration, see
ground_from for more.
This method throws a ValidationError if the string is not ISO norm.
"""
try:
return 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):
"""
An implementation of _serialize.
It is not guaranteed to return the same string as was input,
if ground_from has been used!
"""
return isodate.strftime(value, "P%P")

@staticmethod
def ground_from(
duration: Union[timedelta, isodate.Duration], start: Optional[datetime]
) -> timedelta:
"""
For some valid duration strings (such as "P1M", a month),
converting to a datetime.timedelta is not possible (no obvious
number of days). In this case, `_deserialize` returned an
`isodate.Duration`. We can derive the timedelta by grounding to an
actual time span, for which we require a timezone-aware start datetime.
"""
if isinstance(duration, isodate.Duration) and start:
years = duration.years
months = duration.months
days = duration.days
seconds = duration.tdelta.seconds
offset = pd.DateOffset(
years=years, months=months, days=days, seconds=seconds
)
return (pd.Timestamp(start) + offset).to_pydatetime() - start
return duration
Empty file.
75 changes: 75 additions & 0 deletions flexmeasures/api/common/tests/test_schemas.py
@@ -0,0 +1,75 @@
from datetime import datetime, timedelta

import pytest
import pytz
import isodate
from marshmallow import ValidationError

from flexmeasures.api.common.schemas import DurationField


@pytest.mark.parametrize(
"duration_input,exp_deserialization",
[
("PT1H", timedelta(hours=1)),
("PT6M", timedelta(minutes=6)),
("PT6H", timedelta(hours=6)),
("P2DT1H", timedelta(hours=49)),
],
)
def test_duration_field_straightforward(duration_input, exp_deserialization):
"""Testing straightforward cases"""
df = DurationField()
deser = df.deserialize(duration_input, None, None)
assert deser == exp_deserialization
assert df.serialize("duration", {"duration": deser}) == duration_input


@pytest.mark.parametrize(
"duration_input,exp_deserialization,grounded_timedelta",
[
("P1M", isodate.Duration(months=1), timedelta(days=29)),
("PT24H", isodate.Duration(hours=24), timedelta(hours=24)),
("P2D", isodate.Duration(hours=48), timedelta(hours=48)),
# following are calendar periods including a transition to daylight saving time (DST)
("P2M", isodate.Duration(months=2), timedelta(days=60) - timedelta(hours=1)),
# ("P8W", isodate.Duration(days=7*8), timedelta(weeks=8) - timedelta(hours=1)),
# ("P100D", isodate.Duration(days=100), timedelta(days=100) - timedelta(hours=1)),
# following is a calendar period with transitions to DST and back again
("P1Y", isodate.Duration(years=1), timedelta(days=366)),
],
)
def test_duration_field_nominal_grounded(
duration_input, exp_deserialization, grounded_timedelta
):
"""Nominal durations are tricky:
https://en.wikipedia.org/wiki/Talk:ISO_8601/Archive_2#Definition_of_Duration_is_incorrect
We want to test if we can ground them as expected.
We use a particular datetime to ground, in a leap year February.
For the Europe/Amsterdam timezone, daylight saving time started on March 29th 2020.
# todo: the commented out tests would work if isodate.parse_duration would have the option to stop coercing ISO 8601 days into datetime.timedelta days
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""
df = DurationField()
deser = df.deserialize(duration_input, None, None)
assert deser == exp_deserialization
dummy_time = pytz.timezone("Europe/Amsterdam").localize(
datetime(2020, 2, 22, 18, 7)
)
grounded = DurationField.ground_from(deser, dummy_time)
assert grounded == grounded_timedelta


@pytest.mark.parametrize(
"duration_input,error_msg",
[
("", "Unable to parse duration string"),
("1H", "Unable to parse duration string"),
("PP1M", "time designator 'T' missing"),
("PT2D", "Unrecognised ISO 8601 date format"),
],
)
def test_duration_field_invalid(duration_input, error_msg):
df = DurationField()
with pytest.raises(ValidationError) as ve:
df.deserialize(duration_input, None, None)
assert error_msg in str(ve)
35 changes: 12 additions & 23 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 DurationField
from flexmeasures.api.common.responses import ( # noqa: F401
required_info_missing,
invalid_horizon,
Expand Down Expand Up @@ -134,30 +134,19 @@ def parse_duration(
Parses the 'duration' string into a Duration object.
If needed, try deriving the timedelta from the actual time span (e.g. in case duration is 1 year).
If the string is not a valid ISO 8601 time interval, return None.

TODO: Deprecate for DurationField.
"""
try:
duration = isodate.parse_duration(duration_str)
if not isinstance(duration, timedelta):
if start:
return (start + duration) - start
return duration # valid duration, but not a timedelta (e.g. "P1Y" could be leap year)
else:
return isodate.parse_duration(duration_str)
if not isinstance(duration, timedelta) and start:
return (start + duration) - start
# if not a timedelta, then it's a valid duration (e.g. "P1Y" could be leap year)
return duration
except (ISO8601Error, AttributeError):
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,18 +192,18 @@ def wrapper(fn):
@as_json
def decorated_service(*args, **kwargs):
duration_arg = parser.parse(
{"duration": fields.Str(validate=validate_duration_field)},
{"duration": DurationField()},
request,
location="args_and_json",
unknown=marshmallow.EXCLUDE,
)

if "duration" in duration_arg:
duration = parse_duration(
duration_arg["duration"],
duration = duration_arg["duration"]
duration = DurationField.ground_from(
duration,
kwargs.get("start", kwargs.get("datetime", None)),
)
if not duration:
if not duration: # TODO: deprecate
extra_info = "Cannot parse 'duration' value."
current_app.logger.warning(extra_info)
return invalid_period(extra_info)
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