Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
first version of a chart available by API & add schema.DurationField (#…
…39) The chart which is available is the power chart. Others can follow if requested, but we also have WIP on a chart API based on Altair. This PR also works on further marshmallow integration. We start our schemas by implementing our custom DurationField. Co-authored-by: F.N. Claessen <felix@seita.nl>
- Loading branch information
Showing
16 changed files
with
368 additions
and
62 deletions.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
from datetime import datetime, timedelta | ||
|
||
import pytest | ||
import pytz | ||
import isodate | ||
|
||
from flexmeasures.api.common.schemas.times import DurationField, DurationValidationError | ||
|
||
|
||
@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 | ||
""" | ||
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(DurationValidationError) as ve: | ||
df.deserialize(duration_input, None, None) | ||
assert error_msg in str(ve) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
from typing import Union, Optional | ||
from datetime import datetime, timedelta | ||
|
||
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 | ||
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 DurationValidationError( | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.