storage.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. from __future__ import annotations
  2. from datetime import datetime, timedelta
  3. from flask import current_app
  4. from marshmallow import (
  5. Schema,
  6. post_load,
  7. validate,
  8. validates_schema,
  9. fields,
  10. validates,
  11. )
  12. from marshmallow.validate import OneOf, ValidationError
  13. from flexmeasures.data.models.time_series import Sensor
  14. from flexmeasures.data.schemas.units import QuantityField
  15. from flexmeasures.data.schemas.sensors import VariableQuantityField
  16. from flexmeasures.utils.unit_utils import ur
  17. class EfficiencyField(QuantityField):
  18. """Field that deserializes to a Quantity with % units. Must be greater than 0% and less than or equal to 100%.
  19. Examples:
  20. >>> ef = EfficiencyField()
  21. >>> ef.deserialize(0.9)
  22. <Quantity(90.0, 'percent')>
  23. >>> ef.deserialize("90%")
  24. <Quantity(90, 'percent')>
  25. >>> ef.deserialize("0%")
  26. Traceback (most recent call last):
  27. ...
  28. marshmallow.exceptions.ValidationError: ['Must be greater than 0 and less than or equal to 1.']
  29. """
  30. def __init__(self, *args, **kwargs):
  31. super().__init__(
  32. "%",
  33. validate=validate.Range(
  34. min=0, max=1, min_inclusive=False, max_inclusive=True
  35. ),
  36. *args,
  37. **kwargs,
  38. )
  39. class StorageFlexModelSchema(Schema):
  40. """
  41. This schema lists fields we require when scheduling storage assets.
  42. Some fields are not required, as they might live on the Sensor.attributes.
  43. You can use StorageScheduler.deserialize_flex_config to get that filled in.
  44. """
  45. soc_at_start = QuantityField(
  46. required=False,
  47. to_unit="MWh",
  48. default_src_unit="dimensionless", # placeholder, overridden in __init__
  49. return_magnitude=True,
  50. data_key="soc-at-start",
  51. )
  52. soc_min = QuantityField(
  53. validate=validate.Range(
  54. min=0
  55. ), # change to min=ur.Quantity("0 MWh") in case return_magnitude=False
  56. to_unit="MWh",
  57. default_src_unit="dimensionless", # placeholder, overridden in __init__
  58. return_magnitude=True,
  59. data_key="soc-min",
  60. )
  61. soc_max = QuantityField(
  62. to_unit="MWh",
  63. default_src_unit="dimensionless", # placeholder, overridden in __init__
  64. return_magnitude=True,
  65. data_key="soc-max",
  66. )
  67. power_capacity_in_mw = VariableQuantityField(
  68. "MW", required=False, data_key="power-capacity"
  69. )
  70. consumption_capacity = VariableQuantityField(
  71. "MW", data_key="consumption-capacity", required=False
  72. )
  73. production_capacity = VariableQuantityField(
  74. "MW", data_key="production-capacity", required=False
  75. )
  76. # Activation prices
  77. prefer_curtailing_later = fields.Bool(
  78. data_key="prefer-curtailing-later", load_default=True
  79. )
  80. prefer_charging_sooner = fields.Bool(
  81. data_key="prefer-charging-sooner", load_default=True
  82. )
  83. # Timezone placeholders for the soc_maxima, soc_minima and soc_targets fields are overridden in __init__
  84. soc_maxima = VariableQuantityField(
  85. to_unit="MWh",
  86. default_src_unit="dimensionless", # placeholder, overridden in __init__
  87. timezone="placeholder",
  88. data_key="soc-maxima",
  89. )
  90. soc_minima = VariableQuantityField(
  91. to_unit="MWh",
  92. default_src_unit="dimensionless", # placeholder, overridden in __init__
  93. timezone="placeholder",
  94. data_key="soc-minima",
  95. value_validator=validate.Range(min=0),
  96. )
  97. soc_targets = VariableQuantityField(
  98. to_unit="MWh",
  99. default_src_unit="dimensionless", # placeholder, overridden in __init__
  100. timezone="placeholder",
  101. data_key="soc-targets",
  102. )
  103. soc_unit = fields.Str(
  104. validate=OneOf(
  105. [
  106. "kWh",
  107. "MWh",
  108. ]
  109. ),
  110. data_key="soc-unit",
  111. required=False,
  112. )
  113. state_of_charge = VariableQuantityField(
  114. to_unit="MWh",
  115. data_key="state-of-charge",
  116. required=False,
  117. )
  118. charging_efficiency = VariableQuantityField(
  119. "%", data_key="charging-efficiency", required=False
  120. )
  121. discharging_efficiency = VariableQuantityField(
  122. "%", data_key="discharging-efficiency", required=False
  123. )
  124. roundtrip_efficiency = EfficiencyField(
  125. data_key="roundtrip-efficiency", required=False
  126. )
  127. storage_efficiency = VariableQuantityField("%", data_key="storage-efficiency")
  128. soc_gain = fields.List(
  129. VariableQuantityField("MW"),
  130. data_key="soc-gain",
  131. required=False,
  132. validate=validate.Length(min=1),
  133. )
  134. soc_usage = fields.List(
  135. VariableQuantityField("MW"),
  136. data_key="soc-usage",
  137. required=False,
  138. validate=validate.Length(min=1),
  139. )
  140. def __init__(
  141. self,
  142. start: datetime,
  143. sensor: Sensor,
  144. *args,
  145. default_soc_unit: str | None = None,
  146. **kwargs,
  147. ):
  148. """Pass the schedule's start, so we can use it to validate soc-target datetimes."""
  149. self.start = start
  150. self.sensor = sensor
  151. # guess default soc-unit
  152. if default_soc_unit is None:
  153. if self.sensor.unit in ("MWh", "kWh"):
  154. default_soc_unit = self.sensor.unit
  155. elif self.sensor.unit in ("MW", "kW"):
  156. default_soc_unit = self.sensor.unit + "h"
  157. self.soc_maxima = VariableQuantityField(
  158. to_unit="MWh",
  159. default_src_unit=default_soc_unit,
  160. timezone=sensor.timezone,
  161. data_key="soc-maxima",
  162. )
  163. self.soc_minima = VariableQuantityField(
  164. to_unit="MWh",
  165. default_src_unit=default_soc_unit,
  166. timezone=sensor.timezone,
  167. data_key="soc-minima",
  168. value_validator=validate.Range(min=0),
  169. )
  170. self.soc_targets = VariableQuantityField(
  171. to_unit="MWh",
  172. default_src_unit=default_soc_unit,
  173. timezone=sensor.timezone,
  174. data_key="soc-targets",
  175. )
  176. super().__init__(*args, **kwargs)
  177. if default_soc_unit is not None:
  178. for field in self.fields.keys():
  179. if field.startswith("soc_"):
  180. setattr(self.fields[field], "default_src_unit", default_soc_unit)
  181. @validates_schema
  182. def check_whether_targets_exceed_max_planning_horizon(self, data: dict, **kwargs):
  183. soc_targets: list[dict[str, datetime | float] | Sensor] | None = data.get(
  184. "soc_targets"
  185. )
  186. # skip check if the SOC targets are not provided or if they are defined as sensors
  187. if not soc_targets or isinstance(soc_targets, Sensor):
  188. return
  189. max_server_horizon = current_app.config.get("FLEXMEASURES_MAX_PLANNING_HORIZON")
  190. if isinstance(max_server_horizon, int):
  191. max_server_horizon *= self.sensor.event_resolution
  192. max_target_datetime = max([target["end"] for target in soc_targets])
  193. max_server_datetime = self.start + max_server_horizon
  194. if max_target_datetime > max_server_datetime:
  195. current_app.logger.warning(
  196. f"Target datetime exceeds {max_server_datetime}. Maximum scheduling horizon is {max_server_horizon}."
  197. )
  198. @validates("state_of_charge")
  199. def validate_state_of_charge_is_sensor(
  200. self, state_of_charge: Sensor | list[dict] | ur.Quantity
  201. ):
  202. if not isinstance(state_of_charge, Sensor):
  203. raise ValidationError(
  204. "The `state-of-charge` field can only be a Sensor. In the future, the state-of-charge field will replace soc-at-start field."
  205. )
  206. if state_of_charge.event_resolution != timedelta(0):
  207. raise ValidationError(
  208. "The field `state-of-charge` points to a sensor with a non-instantaneous event resolution. Please, use an instantaneous sensor."
  209. )
  210. @validates("storage_efficiency")
  211. def validate_storage_efficiency_resolution(self, unit: Sensor | ur.Quantity):
  212. if (
  213. isinstance(unit, Sensor)
  214. and unit.event_resolution != self.sensor.event_resolution
  215. ):
  216. raise ValidationError(
  217. "Event resolution of the storage efficiency and the power sensor don't match. Resampling the storage efficiency is not supported."
  218. )
  219. @validates_schema
  220. def check_redundant_efficiencies(self, data: dict, **kwargs):
  221. """
  222. Check that none of the following cases occurs:
  223. (1) flex-model contains both a round-trip efficiency and a charging efficiency
  224. (2) flex-model contains both a round-trip efficiency and a discharging efficiency
  225. (3) flex-model contains a round-trip efficiency, a charging efficiency and a discharging efficiency
  226. :raise: ValidationError
  227. """
  228. for field in ["charging_efficiency", "discharging_efficiency"]:
  229. if field in data and "roundtrip_efficiency" in data:
  230. raise ValidationError(
  231. f"Fields `{field}` and `roundtrip_efficiency` are mutually exclusive."
  232. )
  233. @post_load
  234. def post_load_sequence(self, data: dict, **kwargs) -> dict:
  235. """Perform some checks and corrections after we loaded."""
  236. # currently we only handle MWh internally, and the conversion to MWh happened during deserialization
  237. data["soc_unit"] = "MWh"
  238. # Convert efficiency to dimensionless (to the (0,1] range)
  239. if data.get("roundtrip_efficiency") is not None:
  240. data["roundtrip_efficiency"] = (
  241. data["roundtrip_efficiency"].to(ur.Quantity("dimensionless")).magnitude
  242. )
  243. return data