test_scheduling.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. from datetime import datetime
  2. import pytz
  3. import pytest
  4. from marshmallow.validate import ValidationError
  5. import pandas as pd
  6. from flexmeasures.data.schemas.scheduling import FlexContextSchema, DBFlexContextSchema
  7. from flexmeasures.data.schemas.scheduling.process import (
  8. ProcessSchedulerFlexModelSchema,
  9. ProcessType,
  10. )
  11. from flexmeasures.data.schemas.scheduling.storage import (
  12. StorageFlexModelSchema,
  13. )
  14. from flexmeasures.data.schemas.sensors import TimedEventSchema
  15. @pytest.mark.parametrize(
  16. ["timing_input", "expected_start", "expected_end"],
  17. [
  18. (
  19. {"datetime": "2023-03-27T00:00:00+02:00"},
  20. "2023-03-27T00:00:00+02:00",
  21. "2023-03-27T00:00:00+02:00",
  22. ),
  23. (
  24. {"start": "2023-03-26T00:00:00+01:00", "end": "2023-03-27T00:00:00+02:00"},
  25. "2023-03-26T00:00:00+01:00",
  26. "2023-03-27T00:00:00+02:00",
  27. ),
  28. (
  29. {"start": "2023-03-26T00:00:00+01:00", "duration": "PT24H"},
  30. "2023-03-26T00:00:00+01:00",
  31. "2023-03-27T01:00:00+02:00",
  32. ),
  33. # https://github.com/gweis/isodate/issues/74
  34. # (
  35. # {"start": "2023-03-26T00:00:00+01:00", "duration": "P1D"},
  36. # "2023-03-26T00:00:00+01:00",
  37. # "2023-03-27T00:00:00+02:00",
  38. # ),
  39. # (
  40. # {"start": "2023-03-26T00:00:00+01:00", "duration": "P1W"},
  41. # "2023-03-26T00:00:00+01:00",
  42. # "2023-04-02T00:00:00+02:00",
  43. # ),
  44. (
  45. {"start": "2023-03-26T00:00:00+01:00", "duration": "P1M"},
  46. "2023-03-26T00:00:00+01:00",
  47. "2023-04-26T00:00:00+02:00",
  48. ),
  49. (
  50. {"end": "2023-03-27T00:00:00+02:00", "duration": "PT24H"},
  51. "2023-03-25T23:00:00+01:00",
  52. "2023-03-27T00:00:00+02:00",
  53. ),
  54. (
  55. {"start": "2023-10-29T00:00:00+02:00", "duration": "PT24H"},
  56. "2023-10-29T00:00:00+02:00",
  57. "2023-10-29T23:00:00+01:00",
  58. ),
  59. # https://github.com/gweis/isodate/issues/74
  60. # (
  61. # {"start": "2023-10-29T00:00:00+02:00", "duration": "P1D"},
  62. # "2023-10-29T00:00:00+02:00",
  63. # "2023-10-30T00:00:00+01:00",
  64. # ),
  65. # (
  66. # {"start": "2023-10-29T00:00:00+02:00", "duration": "P1W"},
  67. # "2023-10-29T00:00:00+02:00",
  68. # "2023-11-05T00:00:00+01:00",
  69. # ),
  70. (
  71. {"start": "2023-10-29T00:00:00+02:00", "duration": "P1M"},
  72. "2023-10-29T00:00:00+02:00",
  73. "2023-11-29T00:00:00+01:00",
  74. ),
  75. (
  76. {"end": "2023-11-29T00:00:00+01:00", "duration": "P1M"},
  77. "2023-10-29T00:00:00+02:00",
  78. "2023-11-29T00:00:00+01:00",
  79. ),
  80. ],
  81. )
  82. def test_soc_value_field(timing_input, expected_start, expected_end):
  83. data = TimedEventSchema(timezone="Europe/Amsterdam").load(
  84. {
  85. "value": 3,
  86. **timing_input,
  87. }
  88. )
  89. print(data)
  90. assert data["start"] == pd.Timestamp(expected_start)
  91. assert data["end"] == pd.Timestamp(expected_end)
  92. def test_process_scheduler_flex_model_load(db, app, setup_dummy_sensors):
  93. sensor1, _ = setup_dummy_sensors
  94. schema = ProcessSchedulerFlexModelSchema(
  95. sensor=sensor1,
  96. start=datetime(2023, 1, 1, tzinfo=pytz.UTC),
  97. end=datetime(2023, 1, 2, tzinfo=pytz.UTC),
  98. )
  99. process_scheduler_flex_model = schema.load(
  100. {
  101. "duration": "PT4H",
  102. "power": 30.0,
  103. "time-restrictions": [
  104. {"start": "2023-01-01T00:00:00+00:00", "duration": "PT3H"}
  105. ],
  106. }
  107. )
  108. assert process_scheduler_flex_model["process_type"] == ProcessType.INFLEXIBLE
  109. def test_process_scheduler_flex_model_process_type(db, app, setup_dummy_sensors):
  110. sensor1, _ = setup_dummy_sensors
  111. # checking default
  112. schema = ProcessSchedulerFlexModelSchema(
  113. sensor=sensor1,
  114. start=datetime(2023, 1, 1, tzinfo=pytz.UTC),
  115. end=datetime(2023, 1, 2, tzinfo=pytz.UTC),
  116. )
  117. process_scheduler_flex_model = schema.load(
  118. {
  119. "duration": "PT4H",
  120. "power": 30.0,
  121. "time-restrictions": [
  122. {"start": "2023-01-01T00:00:00+00:00", "duration": "PT3H"}
  123. ],
  124. }
  125. )
  126. assert process_scheduler_flex_model["process_type"] == ProcessType.INFLEXIBLE
  127. sensor1.attributes["process-type"] = "SHIFTABLE"
  128. schema = ProcessSchedulerFlexModelSchema(
  129. sensor=sensor1,
  130. start=datetime(2023, 1, 1, tzinfo=pytz.UTC),
  131. end=datetime(2023, 1, 2, tzinfo=pytz.UTC),
  132. )
  133. process_scheduler_flex_model = schema.load(
  134. {
  135. "duration": "PT4H",
  136. "power": 30.0,
  137. "time-restrictions": [
  138. {"start": "2023-01-01T00:00:00+00:00", "duration": "PT3H"}
  139. ],
  140. }
  141. )
  142. assert process_scheduler_flex_model["process_type"] == ProcessType.SHIFTABLE
  143. @pytest.mark.parametrize(
  144. "fields, fails",
  145. [
  146. (
  147. [
  148. "charging-efficiency",
  149. ],
  150. False,
  151. ),
  152. (
  153. [
  154. "discharging-efficiency",
  155. ],
  156. False,
  157. ),
  158. (["discharging-efficiency", "charging-efficiency"], False),
  159. (
  160. ["discharging-efficiency", "charging-efficiency", "roundtrip_efficiency"],
  161. True,
  162. ),
  163. (["discharging-efficiency", "roundtrip-efficiency"], True),
  164. (["charging-efficiency", "roundtrip-efficiency"], True),
  165. (["roundtrip-efficiency"], False),
  166. ],
  167. )
  168. def test_efficiency_pair(
  169. db, app, setup_dummy_sensors, setup_efficiency_sensors, fields, fails
  170. ):
  171. """
  172. Check that the efficiency can only be defined by the roundtrip efficiency field
  173. or by the (dis)charging efficiency fields.
  174. """
  175. sensor1, _ = setup_dummy_sensors
  176. schema = StorageFlexModelSchema(
  177. sensor=sensor1,
  178. start=datetime(2023, 1, 1, tzinfo=pytz.UTC),
  179. )
  180. def load_schema():
  181. flex_model = {
  182. "storage-efficiency": 1,
  183. "soc-at-start": "0 MWh",
  184. }
  185. for f in fields:
  186. flex_model[f] = "90%"
  187. schema.load(flex_model)
  188. if fails:
  189. with pytest.raises(ValidationError):
  190. load_schema()
  191. else:
  192. load_schema()
  193. @pytest.mark.parametrize(
  194. ["flex_context", "fails"],
  195. [
  196. (
  197. {"site-power-capacity": -1},
  198. {"site-power-capacity": "Unsupported value type"},
  199. ),
  200. (
  201. {"site-power-capacity": "-1 MVA"},
  202. {"site-power-capacity": "Must be greater than or equal to 0."},
  203. ),
  204. (
  205. {"site-power-capacity": "1 MVA"},
  206. False,
  207. ),
  208. (
  209. {"site-power-capacity": {"sensor": "site-power-capacity"}},
  210. False,
  211. ),
  212. (
  213. {
  214. "consumption-price": "1 KRW/MWh",
  215. "site-peak-production-price": "1 EUR/MW",
  216. },
  217. {"site-peak-production-price": "Prices must share the same monetary unit."},
  218. ),
  219. (
  220. {
  221. "consumption-price": "1 MKRW/MWh",
  222. "site-peak-production-price": "1 KRW/MW",
  223. },
  224. False,
  225. ),
  226. (
  227. {
  228. "site-peak-production-price": "-1 KRW/MW",
  229. },
  230. {"site-peak-production-price": "Must be greater than or equal to 0."},
  231. ),
  232. (
  233. {
  234. "site-consumption-breach-price": [
  235. {
  236. "value": "1 KRW/MWh",
  237. "start": "2025-03-16T00:00+01",
  238. "duration": "P1D",
  239. },
  240. {
  241. "value": "1 KRW/MW",
  242. "start": "2025-03-16T00:00+01",
  243. "duration": "P1D",
  244. },
  245. ],
  246. },
  247. {
  248. "site-consumption-breach-price": "Segments of a time series must share the same unit."
  249. },
  250. ),
  251. ],
  252. )
  253. def test_flex_context_schema(db, app, setup_site_capacity_sensor, flex_context, fails):
  254. schema = FlexContextSchema()
  255. # Replace sensor name with sensor ID
  256. for field_name, field_value in flex_context.items():
  257. if isinstance(field_value, dict):
  258. flex_context[field_name]["sensor"] = setup_site_capacity_sensor[
  259. field_value["sensor"]
  260. ].id
  261. if fails:
  262. with pytest.raises(ValidationError) as e_info:
  263. schema.load(flex_context)
  264. print(e_info.value.messages)
  265. for field_name, expected_message in fails.items():
  266. assert field_name in e_info.value.messages
  267. # Check all messages for the given field for the expected message
  268. assert any(
  269. [
  270. expected_message in message
  271. for message in e_info.value.messages[field_name]
  272. ]
  273. )
  274. else:
  275. schema.load(flex_context)
  276. # test DBFlexContextSchema
  277. @pytest.mark.parametrize(
  278. ["flex_context", "fails"],
  279. [
  280. (
  281. {"consumption-price": "13000 kW"},
  282. {
  283. "consumption-price": "Fixed prices are not currently supported for consumption-price in flex-context fields in the DB.",
  284. },
  285. ),
  286. (
  287. {
  288. "production-price": {
  289. "sensor": "placeholder for site-power-capacity sensor"
  290. }
  291. },
  292. {
  293. "production-price": "Energy price field 'production-price' must have an energy price unit."
  294. },
  295. ),
  296. (
  297. {"production-price": {"sensor": "placeholder for price sensor"}},
  298. False,
  299. ),
  300. (
  301. {"consumption-price": "100 EUR/MWh"},
  302. {
  303. "consumption-price": "Fixed prices are not currently supported for consumption-price in flex-context fields in the DB.",
  304. },
  305. ),
  306. (
  307. {"production-price": "100 EUR/MW"},
  308. {
  309. "production-price": "Fixed prices are not currently supported for production-price in flex-context fields in the DB."
  310. },
  311. ),
  312. (
  313. {"site-power-capacity": 100},
  314. {
  315. "site-power-capacity": f"Unsupported value type. `{type(100)}` was provided but only dict, list and str are supported."
  316. },
  317. ),
  318. (
  319. {
  320. "site-power-capacity": [
  321. {
  322. "value": "100 kW",
  323. "start": "2025-03-18T00:00+01:00",
  324. "duration": "P2D",
  325. }
  326. ]
  327. },
  328. {
  329. "site-power-capacity": "A time series specification (listing segments) is not supported when storing flex-context fields. Use a fixed quantity or a sensor reference instead."
  330. },
  331. ),
  332. (
  333. {"site-power-capacity": "5 kWh"},
  334. {"site-power-capacity": "Cannot convert value `5 kWh` to 'MW'"},
  335. ),
  336. (
  337. {"site-consumption-capacity": "6 kWh"},
  338. {"site-consumption-capacity": "Cannot convert value `6 kWh` to 'MW'"},
  339. ),
  340. (
  341. {"site-consumption-capacity": "6000 kW"},
  342. False,
  343. ),
  344. (
  345. {"site-production-capacity": "6 kWh"},
  346. {"site-production-capacity": "Cannot convert value `6 kWh` to 'MW'"},
  347. ),
  348. (
  349. {"site-production-capacity": "7000 kW"},
  350. False,
  351. ),
  352. (
  353. {"site-consumption-breach-price": "6 kWh"},
  354. {
  355. "site-consumption-breach-price": "Capacity price field 'site-consumption-breach-price' must have a capacity price unit."
  356. },
  357. ),
  358. (
  359. {"site-consumption-breach-price": "450 EUR/MW"},
  360. False,
  361. ),
  362. (
  363. {"site-production-breach-price": "550 EUR/MWh"},
  364. {
  365. "site-production-breach-price": "Capacity price field 'site-production-breach-price' must have a capacity price unit."
  366. },
  367. ),
  368. (
  369. {"site-production-breach-price": "3500 EUR/MW"},
  370. False,
  371. ),
  372. (
  373. {"site-peak-consumption": "60 EUR/MWh"},
  374. {"site-peak-consumption": "Cannot convert value `60 EUR/MWh` to 'MW'"},
  375. ),
  376. (
  377. {"site-peak-consumption": "3500 kW"},
  378. False,
  379. ),
  380. (
  381. {"site-peak-consumption-price": "6 orange/Mw"},
  382. {
  383. "site-peak-consumption-price": "Cannot convert value '6 orange/Mw' to a valid quantity. 'orange' is not defined in the unit registry"
  384. },
  385. ),
  386. (
  387. {"site-peak-consumption-price": "100 EUR/MW"},
  388. False,
  389. ),
  390. (
  391. {"site-peak-production": "75kWh"},
  392. {"site-peak-production": "Cannot convert value `75kWh` to 'MW'"},
  393. ),
  394. (
  395. {"site-peak-production": "17000 kW"},
  396. False,
  397. ),
  398. (
  399. {"site-peak-production-price": "4500 EUR/MWh"},
  400. {
  401. "site-peak-production-price": "Capacity price field 'site-peak-production-price' must have a capacity price unit."
  402. },
  403. ),
  404. (
  405. {"site-peak-consumption-price": "700 EUR/MW"},
  406. False,
  407. ),
  408. ],
  409. )
  410. def test_db_flex_context_schema(
  411. db, app, setup_dummy_sensors, setup_site_capacity_sensor, flex_context, fails
  412. ):
  413. schema = DBFlexContextSchema()
  414. price_sensor = setup_dummy_sensors[1]
  415. capacity_sensor = setup_site_capacity_sensor["site-power-capacity"]
  416. # Replace sensor name with sensor ID
  417. for field_name, field_value in flex_context.items():
  418. if isinstance(field_value, dict):
  419. if field_value["sensor"] == "placeholder for site-power-capacity sensor":
  420. flex_context[field_name]["sensor"] = capacity_sensor.id
  421. elif field_value["sensor"] == "placeholder for price sensor":
  422. flex_context[field_name]["sensor"] = price_sensor.id
  423. if fails:
  424. with pytest.raises(ValidationError) as e_info:
  425. schema.load(flex_context)
  426. print(e_info.value.messages)
  427. for field_name, expected_message in fails.items():
  428. assert field_name in e_info.value.messages
  429. # Check all messages for the given field for the expected message
  430. assert any(
  431. [
  432. expected_message in message
  433. for message in e_info.value.messages[field_name]
  434. ]
  435. )
  436. else:
  437. schema.load(flex_context)