__init__.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. from __future__ import annotations
  2. from dataclasses import dataclass, field
  3. from datetime import datetime, timedelta
  4. from typing import Any, Dict, List, Type, Union
  5. import pandas as pd
  6. from flask import current_app
  7. from flexmeasures.data.models.time_series import Sensor
  8. from flexmeasures.data.models.generic_assets import GenericAsset as Asset
  9. from flexmeasures.utils.coding_utils import deprecated
  10. from .exceptions import WrongEntityException
  11. # todo: Use | instead of Union, list instead of List and dict instead of Dict when FM stops supporting Python 3.9 (because of https://github.com/python/cpython/issues/86399)
  12. SchedulerOutputType = Union[pd.Series, List[Dict[str, Any]], None]
  13. class Scheduler:
  14. """
  15. Superclass for all FlexMeasures Schedulers.
  16. A scheduler currently computes the schedule for one flexible asset.
  17. TODO: extend to multiple flexible assets.
  18. The scheduler knows the power sensor of the flexible asset.
  19. It also knows the basic timing parameter of the schedule (start, end, resolution), including the point in time when
  20. knowledge can be assumed to be available (belief_time).
  21. Furthermore, the scheduler needs to have knowledge about the asset's flexibility model (under what constraints
  22. can the schedule be optimized?) and the system's flexibility context (which other sensors are relevant, e.g. prices).
  23. These two flexibility configurations are usually fed in from outside, so the scheduler should check them.
  24. The deserialize_flex_config function can be used for that.
  25. """
  26. __version__ = None
  27. __author__ = None
  28. sensor: Sensor | None = None
  29. asset: Asset | None = None
  30. start: datetime
  31. end: datetime
  32. resolution: timedelta
  33. belief_time: datetime
  34. round_to_decimals: int
  35. flex_model: dict | None = None
  36. flex_context: dict | None = None
  37. fallback_scheduler_class: "Type[Scheduler] | None" = None
  38. info: dict | None = None
  39. config_deserialized = False # This flag allows you to let the scheduler skip checking config, like timing, flex_model and flex_context
  40. # set to True if the Scheduler supports triggering on an Asset or False
  41. # if the Scheduler expects a Sensor
  42. supports_scheduling_an_asset = False
  43. return_multiple: bool = False
  44. def __init__(
  45. self,
  46. sensor: Sensor | None = None, # deprecated
  47. start: datetime | None = None,
  48. end: datetime | None = None,
  49. resolution: timedelta | None = None,
  50. belief_time: datetime | None = None,
  51. asset_or_sensor: Asset | Sensor | None = None,
  52. round_to_decimals: int | None = 6,
  53. flex_model: dict | None = None,
  54. flex_context: dict | None = None,
  55. return_multiple: bool = False,
  56. ):
  57. """
  58. Initialize a new Scheduler.
  59. TODO: We might adapt the class design, so that a Scheduler object is initialized with configuration parameters,
  60. and can then be used multiple times (via compute()) to compute schedules of different kinds, e.g.
  61. If we started later (put in a later start), what would the schedule be?
  62. If we could change set points less often (put in a coarser resolution), what would the schedule be?
  63. If we knew what was going to happen (put in a later belief_time), what would the schedule have been?
  64. For now, we don't see the best separation between config and state parameters (esp. within flex models)
  65. E.g. start and flex_model[soc_at_start] are intertwined.
  66. """
  67. if sensor is not None:
  68. current_app.logger.warning(
  69. "The `sensor` keyword argument is deprecated. Please, consider using the argument `asset_or_sensor`."
  70. )
  71. asset_or_sensor = sensor
  72. if self.supports_scheduling_an_asset and isinstance(asset_or_sensor, Sensor):
  73. raise WrongEntityException(
  74. f"The scheduler class {self.__class__.__name__} expects an Asset object but a Sensor was provided."
  75. )
  76. self.sensor = None
  77. self.asset = None
  78. if isinstance(asset_or_sensor, Sensor):
  79. self.sensor = asset_or_sensor
  80. elif isinstance(asset_or_sensor, Asset):
  81. self.asset = asset_or_sensor
  82. else:
  83. raise WrongEntityException(
  84. f"The scheduler class {self.__class__.__name__} expects an Asset or Sensor objects but an object of class `{asset_or_sensor.__class__.__name__}` was provided."
  85. )
  86. self.start = start
  87. self.end = end
  88. self.resolution = resolution
  89. self.belief_time = belief_time
  90. self.round_to_decimals = round_to_decimals
  91. if flex_model is None:
  92. flex_model = {}
  93. self.flex_model = flex_model
  94. if flex_context is None:
  95. flex_context = {}
  96. self.flex_context = flex_context
  97. if self.info is None:
  98. self.info = dict(scheduler=self.__class__.__name__)
  99. self.return_multiple = return_multiple
  100. def compute_schedule(self) -> pd.Series | None:
  101. """
  102. Overwrite with the actual computation of your schedule.
  103. Deprecated method in v0.14. As an alternative, use Scheduler.compute().
  104. """
  105. return self.compute()
  106. def compute(self) -> SchedulerOutputType:
  107. """
  108. Overwrite with the actual computation of your schedule.
  109. """
  110. return None
  111. @classmethod
  112. def get_data_source_info(cls: type) -> dict:
  113. """
  114. Create and return the data source info, from which a data source lookup/creation is possible.
  115. See for instance get_data_source_for_job().
  116. """
  117. source_info = dict(
  118. model=cls.__name__, version="1", name="Unknown author"
  119. ) # default
  120. if hasattr(cls, "__version__"):
  121. source_info["version"] = str(cls.__version__)
  122. else:
  123. current_app.logger.warning(
  124. f"Scheduler {cls.__name__} loaded, but has no __version__ attribute."
  125. )
  126. if hasattr(cls, "__author__"):
  127. source_info["name"] = str(cls.__author__)
  128. else:
  129. current_app.logger.warning(
  130. f"Scheduler {cls.__name__} has no __author__ attribute."
  131. )
  132. return source_info
  133. def persist_flex_model(self):
  134. """
  135. If useful, (parts of) the flex model can be persisted here,
  136. e.g. as asset attributes, sensor attributes or as sensor data (beliefs).
  137. """
  138. pass
  139. def deserialize_config(self):
  140. """
  141. Check all configurations we have, throwing either ValidationErrors or ValueErrors.
  142. Other code can decide if/how to handle those.
  143. """
  144. self.deserialize_timing_config()
  145. self.deserialize_flex_config()
  146. self.config_deserialized = True
  147. def deserialize_timing_config(self):
  148. """
  149. Check if the timing of the schedule is valid.
  150. Raises ValueErrors.
  151. """
  152. if self.start > self.end:
  153. raise ValueError(f"Start {self.start} cannot be after end {self.end}.")
  154. # TODO: check if resolution times X fits into schedule length
  155. # TODO: check if scheduled events would start "on the clock" w.r.t. resolution (see GH#10)
  156. def deserialize_flex_config(self):
  157. """
  158. Check if the flex model and flex context are valid. Should be overwritten.
  159. Ideas:
  160. - Apply a schema to check validity (see in-built flex model schemas)
  161. - Check for inconsistencies between settings (can also happen in Marshmallow)
  162. - fill in missing values from the scheduler's knowledge (e.g. sensor attributes)
  163. Raises ValidationErrors or ValueErrors.
  164. """
  165. pass
  166. @dataclass
  167. class Commitment:
  168. """Contractual commitment specifying prices for deviating from a given position.
  169. Attributes:
  170. name: Name of the commitment.
  171. device: Device to which the commitment pertains. If None, the commitment pertains to the EMS.
  172. index: Pandas DatetimeIndex defining the time slots to which the commitment applies.
  173. The index is shared by the group, quantity, upwards_deviation_price and downwards_deviation_price Pandas Series.
  174. _type: 'any' or 'each'. Any deviation is penalized via 1 group, whereas each deviation is penalized via n groups.
  175. group: Each time slot is assigned to a group. Deviations are determined for each group.
  176. The deviation of a group is determined by the time slot with the maximum deviation within that group.
  177. quantity: The deviation for each group is determined with respect to this quantity.
  178. Can be initialized with a constant value, but always returns a Pandas Series (see also the `index` parameter).
  179. upwards_deviation_price:
  180. The deviation in the upwards direction is priced against this price. Use a positive price to set a penalty.
  181. Can be initialized with a constant value, but always returns a Pandas Series (see also the `index` parameter).
  182. downwards_deviation_price:
  183. The deviation in the downwards direction is priced against this price. Use a negative price to set a penalty.
  184. Can be initialized with a constant value, but always returns a Pandas Series (see also the `index` parameter).
  185. """
  186. name: str
  187. device: pd.Series = None
  188. index: pd.DatetimeIndex = field(repr=False, default=None)
  189. _type: str = field(repr=False, default="each")
  190. group: pd.Series = field(init=False)
  191. quantity: pd.Series = 0
  192. upwards_deviation_price: pd.Series = 0
  193. downwards_deviation_price: pd.Series = 0
  194. def __post_init__(self):
  195. series_attributes = [
  196. attr
  197. for attr, _type in self.__annotations__.items()
  198. if _type == "pd.Series" and hasattr(self, attr)
  199. ]
  200. for series_attr in series_attributes:
  201. val = getattr(self, series_attr)
  202. # Convert from single-column DataFrame to Series
  203. if isinstance(val, pd.DataFrame):
  204. val = val.squeeze()
  205. setattr(self, series_attr, val)
  206. # Try to set the time series index for the commitment
  207. if (
  208. self.index is None
  209. and isinstance(getattr(self, series_attr), pd.Series)
  210. and isinstance(val.index, pd.DatetimeIndex)
  211. ):
  212. self.index = val.index
  213. if self.index is None:
  214. raise ValueError(
  215. "Commitment must be initialized with a pd.DatetimeIndex. Hint: use the `index` argument."
  216. )
  217. # Force type conversion of repr fields to pd.Series
  218. for series_attr in series_attributes:
  219. val = getattr(self, series_attr)
  220. if not isinstance(val, pd.Series):
  221. setattr(self, series_attr, pd.Series(val, index=self.index))
  222. if self._type == "any":
  223. # add all time steps to the same group
  224. self.group = pd.Series(0, index=self.index)
  225. elif self._type == "each":
  226. # add each time step to their own group
  227. self.group = pd.Series(list(range(len(self.index))), index=self.index)
  228. else:
  229. raise ValueError('Commitment `_type` must be "any" or "each".')
  230. # Name the Series as expected by our device scheduler
  231. self.device = self.device.rename("device")
  232. self.quantity = self.quantity.rename("quantity")
  233. self.upwards_deviation_price = self.upwards_deviation_price.rename(
  234. "upwards deviation price"
  235. )
  236. self.downwards_deviation_price = self.downwards_deviation_price.rename(
  237. "downwards deviation price"
  238. )
  239. self.group = self.group.rename("group")
  240. def to_frame(self) -> pd.DataFrame:
  241. """Contains all info apart from the name."""
  242. return pd.concat(
  243. [
  244. self.device,
  245. self.quantity,
  246. self.upwards_deviation_price,
  247. self.downwards_deviation_price,
  248. self.group,
  249. pd.Series(self.__class__, index=self.index, name="class"),
  250. ],
  251. axis=1,
  252. )
  253. class FlowCommitment(Commitment):
  254. """NB index contains event start, while quantity applies to average flow between event start and end."""
  255. pass
  256. class StockCommitment(Commitment):
  257. """NB index contains event start, while quantity applies to stock at event end."""
  258. pass
  259. """
  260. Deprecations
  261. """
  262. Scheduler.compute_schedule = deprecated(Scheduler.compute, "0.14")(
  263. Scheduler.compute_schedule
  264. )