times.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. from __future__ import annotations
  2. import json
  3. from datetime import datetime, timedelta
  4. from flask import current_app
  5. from marshmallow import fields, Schema, validates_schema
  6. from marshmallow.exceptions import ValidationError
  7. import isodate
  8. from isodate.isoerror import ISO8601Error
  9. import pandas as pd
  10. from flexmeasures.data.schemas.utils import FMValidationError, MarshmallowClickMixin
  11. class DurationValidationError(FMValidationError):
  12. status = "INVALID_PERIOD" # USEF error status
  13. class DurationField(MarshmallowClickMixin, fields.Str):
  14. """Field that deserializes to a ISO8601 Duration
  15. and serializes back to a string."""
  16. def _deserialize(self, value, attr, obj, **kwargs) -> timedelta | isodate.Duration:
  17. """
  18. Use the isodate library to turn an ISO8601 string into a timedelta.
  19. For some non-obvious cases, it will become an isodate.Duration, see
  20. ground_from for more.
  21. This method throws a ValidationError if the string is not ISO norm.
  22. """
  23. try:
  24. duration_value = isodate.parse_duration(value)
  25. except ISO8601Error as iso_err:
  26. raise DurationValidationError(
  27. f"Cannot parse {value} as ISO8601 duration: {iso_err}"
  28. )
  29. if duration_value.seconds % 60 != 0 or duration_value.microseconds != 0:
  30. raise DurationValidationError(
  31. "FlexMeasures only support multiples of 1 minute."
  32. )
  33. return duration_value
  34. def _serialize(self, value, attr, data, **kwargs):
  35. """
  36. An implementation of _serialize.
  37. It is not guaranteed to return the same string as was input,
  38. if ground_from has been used!
  39. """
  40. return isodate.strftime(value, "P%P")
  41. @staticmethod
  42. def ground_from(
  43. duration: timedelta | isodate.Duration, start: datetime | None
  44. ) -> timedelta:
  45. """
  46. For some valid duration strings (such as "P1M", a month),
  47. converting to a datetime.timedelta is not possible (no obvious
  48. number of days). In this case, `_deserialize` returned an
  49. `isodate.Duration`. We can derive the timedelta by grounding to an
  50. actual time span, for which we require a timezone-aware start datetime.
  51. """
  52. if isinstance(duration, isodate.Duration) and start:
  53. years = duration.years
  54. months = duration.months
  55. days = duration.days
  56. seconds = duration.tdelta.seconds
  57. offset = pd.DateOffset(
  58. years=years, months=months, days=days, seconds=seconds
  59. )
  60. return (pd.Timestamp(start) + offset).to_pydatetime() - start
  61. return duration
  62. class PlanningDurationField(DurationField):
  63. @classmethod
  64. def load_default(cls):
  65. """
  66. Use this with the load_default arg to __init__ if you want the default FlexMeasures planning horizon.
  67. """
  68. return current_app.config.get("FLEXMEASURES_PLANNING_HORIZON")
  69. class AwareDateTimeField(MarshmallowClickMixin, fields.AwareDateTime):
  70. """Field that de-serializes to a timezone aware datetime
  71. and serializes back to a string."""
  72. def _deserialize(self, value: str, attr, obj, **kwargs) -> datetime:
  73. """
  74. Work-around until this PR lands:
  75. https://github.com/marshmallow-code/marshmallow/pull/1787
  76. """
  77. value = value.replace(" ", "+")
  78. return fields.AwareDateTime._deserialize(self, value, attr, obj, **kwargs)
  79. class TimeIntervalSchema(Schema):
  80. start = AwareDateTimeField(required=True)
  81. duration = DurationField(required=True)
  82. class TimeIntervalField(MarshmallowClickMixin, fields.Dict):
  83. """Field that de-serializes to a TimeInverval defined with start and duration."""
  84. def _deserialize(self, value: str, attr, obj, **kwargs) -> dict:
  85. try:
  86. v = json.loads(value)
  87. except json.JSONDecodeError:
  88. raise ValidationError()
  89. return TimeIntervalSchema().load(v)
  90. class StartEndTimeSchema(Schema):
  91. start_time = AwareDateTimeField(required=False)
  92. end_time = AwareDateTimeField(required=False)
  93. @validates_schema
  94. def validate(self, data, **kwargs):
  95. if not (data.get("start_time") or data.get("end_time")):
  96. return
  97. if not (data.get("start_time") and data.get("end_time")):
  98. raise ValidationError(
  99. "Both start_time and end_time must be provided together."
  100. )
  101. if data["start_time"] >= data["end_time"]:
  102. raise ValidationError("start_time must be before end_time.")