test_solver.py 99 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992
  1. from __future__ import annotations
  2. from datetime import datetime, timedelta
  3. import pytest
  4. import pytz
  5. import logging
  6. import numpy as np
  7. import pandas as pd
  8. from pandas.tseries.frequencies import to_offset
  9. from sqlalchemy import select
  10. from flexmeasures.data.models.time_series import Sensor
  11. from flexmeasures.data.models.planning import Scheduler
  12. from flexmeasures.data.models.planning.exceptions import InfeasibleProblemException
  13. from flexmeasures.data.models.planning.storage import (
  14. StorageScheduler,
  15. add_storage_constraints,
  16. validate_storage_constraints,
  17. build_device_soc_values,
  18. )
  19. from flexmeasures.data.models.planning.linear_optimization import device_scheduler
  20. from flexmeasures.data.models.planning.tests.utils import (
  21. check_constraints,
  22. get_sensors_from_db,
  23. )
  24. from flexmeasures.data.models.planning.utils import (
  25. initialize_device_commitment,
  26. initialize_df,
  27. initialize_energy_commitment,
  28. initialize_series,
  29. )
  30. from flexmeasures.data.schemas.sensors import TimedEventSchema
  31. from flexmeasures.utils.calculations import (
  32. apply_stock_changes_and_losses,
  33. integrate_time_series,
  34. )
  35. from flexmeasures.tests.utils import get_test_sensor
  36. from flexmeasures.utils.unit_utils import convert_units, ur
  37. from pyomo.environ import value
  38. TOLERANCE = 0.00001
  39. @pytest.mark.parametrize(
  40. "initial_stock, stock_deltas, expected_stocks, storage_efficiency",
  41. [
  42. (
  43. 1000,
  44. [100, -100, -100, 100],
  45. [1000, 1089, 979.11, 870.3189, 960.615711],
  46. 0.99,
  47. ),
  48. (
  49. 2.5,
  50. [-0.5, -0.5, -0.5, -0.5],
  51. [2.5, 1.8, 1.17, 0.603, 0.0927],
  52. 0.9,
  53. ),
  54. ],
  55. )
  56. def test_storage_loss_function(
  57. initial_stock, stock_deltas, expected_stocks, storage_efficiency
  58. ):
  59. stocks = apply_stock_changes_and_losses(
  60. initial_stock,
  61. stock_deltas,
  62. storage_efficiency=storage_efficiency,
  63. how="left",
  64. decimal_precision=6,
  65. )
  66. print(stocks)
  67. assert all(a == b for a, b in zip(stocks, expected_stocks))
  68. @pytest.mark.parametrize("use_inflexible_device", [False, True])
  69. @pytest.mark.parametrize("battery_name", ["Test battery", "Test small battery"])
  70. def test_battery_solver_day_1(
  71. add_battery_assets,
  72. add_inflexible_device_forecasts,
  73. use_inflexible_device,
  74. battery_name,
  75. db,
  76. ):
  77. epex_da, battery = get_sensors_from_db(
  78. db, add_battery_assets, battery_name=battery_name
  79. )
  80. tz = pytz.timezone("Europe/Amsterdam")
  81. start = tz.localize(datetime(2015, 1, 1))
  82. end = tz.localize(datetime(2015, 1, 2))
  83. resolution = timedelta(minutes=15)
  84. soc_at_start = battery.get_attribute("soc_in_mwh")
  85. scheduler: Scheduler = StorageScheduler(
  86. battery,
  87. start,
  88. end,
  89. resolution,
  90. flex_model={"soc-at-start": soc_at_start},
  91. flex_context={
  92. "inflexible-device-sensors": (
  93. [s.id for s in add_inflexible_device_forecasts.keys()]
  94. if use_inflexible_device
  95. else []
  96. ),
  97. "site-power-capacity": "2 MW",
  98. },
  99. )
  100. schedule = scheduler.compute()
  101. # Check if constraints were met
  102. check_constraints(battery, schedule, soc_at_start)
  103. @pytest.mark.parametrize(
  104. "roundtrip_efficiency, storage_efficiency",
  105. [
  106. (1, 1),
  107. (1, 0.999),
  108. (1, 0.5),
  109. (0.99, 1),
  110. (0.01, 1),
  111. ],
  112. )
  113. def test_battery_solver_day_2(
  114. add_battery_assets, roundtrip_efficiency: float, storage_efficiency: float, db
  115. ):
  116. """Check battery scheduling results for day 2, which is set up with
  117. 8 expensive, then 8 cheap, then again 8 expensive hours.
  118. If efficiency losses aren't too bad, we expect the scheduler to:
  119. - completely discharge within the first 8 hours
  120. - completely charge within the next 8 hours
  121. - completely discharge within the last 8 hours
  122. If efficiency losses are bad, the price difference is not worth cycling the battery,
  123. and so we expect the scheduler to only:
  124. - completely discharge within the last 8 hours
  125. """
  126. _epex_da, battery = get_sensors_from_db(db, add_battery_assets)
  127. tz = pytz.timezone("Europe/Amsterdam")
  128. start = tz.localize(datetime(2015, 1, 2))
  129. end = tz.localize(datetime(2015, 1, 3))
  130. resolution = timedelta(minutes=15)
  131. soc_at_start = battery.get_attribute("soc_in_mwh")
  132. soc_min = 0.5
  133. soc_max = 4.5
  134. scheduler = StorageScheduler(
  135. battery,
  136. start,
  137. end,
  138. resolution,
  139. flex_model={
  140. "soc-at-start": soc_at_start,
  141. "soc-min": soc_min,
  142. "soc-max": soc_max,
  143. "roundtrip-efficiency": roundtrip_efficiency,
  144. "storage-efficiency": storage_efficiency,
  145. "prefer-curtailing-later": False,
  146. },
  147. )
  148. schedule = scheduler.compute()
  149. # Check if constraints were met
  150. soc_schedule = check_constraints(
  151. battery, schedule, soc_at_start, roundtrip_efficiency, storage_efficiency
  152. )
  153. # Check whether the resulting soc schedule follows our expectations for 8 expensive, 8 cheap and 8 expensive hours
  154. assert soc_schedule.iloc[-1] == max(
  155. soc_min, battery.get_attribute("min_soc_in_mwh")
  156. ) # Battery sold out at the end of its planning horizon
  157. # As long as the efficiencies aren't too bad (I haven't computed the actual switch points)
  158. if roundtrip_efficiency > 0.9 and storage_efficiency > 0.9:
  159. np.testing.assert_approx_equal(
  160. soc_schedule.loc[start + timedelta(hours=8)],
  161. max(soc_min, battery.get_attribute("min_soc_in_mwh")),
  162. significant=3,
  163. ) # Sell what you begin with
  164. assert soc_schedule.loc[start + timedelta(hours=16)] == min(
  165. soc_max, battery.get_attribute("max_soc_in_mwh")
  166. ) # Buy what you can to sell later
  167. elif storage_efficiency > 0.9:
  168. # If only the roundtrip efficiency is poor, best to stand idle (keep a high SoC as long as possible)
  169. assert soc_schedule.loc[start + timedelta(hours=8)] == battery.get_attribute(
  170. "soc_in_mwh"
  171. )
  172. assert soc_schedule.loc[start + timedelta(hours=16)] == battery.get_attribute(
  173. "soc_in_mwh"
  174. )
  175. else:
  176. # If the storage efficiency is poor, regardless of whether the roundtrip efficiency is poor, best to sell asap
  177. assert soc_schedule.loc[start + timedelta(hours=8)] == max(
  178. soc_min, battery.get_attribute("min_soc_in_mwh")
  179. )
  180. assert soc_schedule.loc[start + timedelta(hours=16)] == max(
  181. soc_min, battery.get_attribute("min_soc_in_mwh")
  182. )
  183. def run_test_charge_discharge_sign(
  184. battery,
  185. roundtrip_efficiency,
  186. consumption_price_sensor_id,
  187. production_price_sensor_id,
  188. ):
  189. tz = pytz.timezone("Europe/Amsterdam")
  190. start = tz.localize(datetime(2015, 1, 3))
  191. end = tz.localize(datetime(2015, 1, 4))
  192. resolution = timedelta(hours=1)
  193. storage_efficiency = 1
  194. # Choose the SoC constraints and starting value such that the battery can fully charge or discharge in a single time step
  195. soc_min = 0
  196. capacity = battery.get_attribute(
  197. "capacity_in_mw",
  198. ur.Quantity(battery.get_attribute("site-power-capacity")).to("MW").magnitude,
  199. )
  200. soc_max = capacity
  201. soc_at_start = capacity
  202. scheduler: Scheduler = StorageScheduler(
  203. battery,
  204. start,
  205. end,
  206. resolution,
  207. flex_model={
  208. "soc-min": soc_min,
  209. "soc-max": soc_max,
  210. "soc-at-start": soc_at_start,
  211. "roundtrip-efficiency": roundtrip_efficiency,
  212. "storage-efficiency": storage_efficiency,
  213. "prefer-charging-sooner": True,
  214. "prefer-curtailing-later": False,
  215. },
  216. flex_context={
  217. "consumption-price": {"sensor": consumption_price_sensor_id},
  218. "production-price": {"sensor": production_price_sensor_id},
  219. },
  220. )
  221. (
  222. sensors,
  223. start,
  224. end,
  225. resolution,
  226. soc_at_start,
  227. device_constraints,
  228. ems_constraints,
  229. commitments,
  230. ) = scheduler._prepare(skip_validation=True)
  231. _, _, results, model = device_scheduler(
  232. device_constraints=device_constraints,
  233. ems_constraints=ems_constraints,
  234. commitments=commitments,
  235. initial_stock=[
  236. soc_at_start_d * (timedelta(hours=1) / resolution)
  237. for soc_at_start_d in soc_at_start
  238. ],
  239. )
  240. device_power_sign = pd.Series(model.device_power_sign.extract_values())[0]
  241. device_power_up = pd.Series(model.device_power_up.extract_values())[0]
  242. device_power_down = pd.Series(model.device_power_down.extract_values())[0]
  243. is_power_down = ~np.isclose(abs(device_power_down), 0)
  244. is_power_up = ~np.isclose(abs(device_power_up), 0)
  245. # only one power active at a time
  246. assert (~(is_power_down & is_power_up)).all()
  247. # downwards power not active when the binary variable is 1
  248. assert (~is_power_down[device_power_sign == 1.0]).all()
  249. # upwards power not active when the binary variable is 0
  250. assert (~is_power_up[device_power_sign == 0.0]).all()
  251. schedule = initialize_series(
  252. data=[model.ems_power[0, j].value for j in model.j],
  253. start=start,
  254. end=end,
  255. resolution=to_offset(resolution),
  256. )
  257. # Check if constraints were met
  258. soc_schedule = check_constraints(
  259. battery, schedule, soc_at_start[0], roundtrip_efficiency, storage_efficiency
  260. )
  261. return schedule.tz_convert(tz), soc_schedule.tz_convert(tz)
  262. def test_battery_solver_day_3(add_battery_assets, add_inflexible_device_forecasts, db):
  263. """Check battery scheduling results for day 3, which is set up with
  264. 8 hours with negative prices, followed by 16 expensive hours.
  265. Under certain conditions, batteries can be used to "burn" energy in form of heat, due to the conversion
  266. losses of the inverters. Nonetheless, this doesn't come for free as this is shortening the lifetime of the asset.
  267. For this reason, the constraints `device_up_derivative_sign` and `device_down_derivative_sign' make sure that
  268. the storage can only charge or discharge within the same time period.
  269. These constraints don't avoid burning energy in Case 1) in which a storage with conversion losses operating under the
  270. same buy/sell prices.
  271. Nonetheless, as shown in Cases 3) and 4), the oscillatory dynamic is gone when having Consumption Price > Production Price.
  272. This is because even though the energy consumed is bigger than that produced, the difference between the cost of consuming and the
  273. revenue of producing doesn't create a profit.
  274. """
  275. roundtrip_efficiency = 0.9
  276. epex_da = get_test_sensor(db)
  277. epex_da_production = db.session.execute(
  278. select(Sensor).filter_by(name="epex_da_production")
  279. ).scalar_one_or_none()
  280. battery = add_battery_assets["Test battery"].sensors[0]
  281. tz = pytz.timezone("Europe/Amsterdam")
  282. start = tz.localize(datetime(2015, 1, 3))
  283. # Case 1: Consumption Price = Production Price, roundtrip_efficiency < 1
  284. schedule_1, soc_schedule_1 = run_test_charge_discharge_sign(
  285. battery, roundtrip_efficiency, epex_da.id, epex_da.id
  286. )
  287. # For the negative price period, the schedule shows oscillations
  288. # discharge in even hours
  289. assert all(schedule_1[:8:2] < 0) # 12am, 2am, 4am, 6am
  290. # charge in odd hours
  291. assert all(schedule_1[1:8:2] > 0) # 1am, 3am, 5am, 7am
  292. # in positive price hours, the battery will only discharge to sell the energy charged in the negative hours
  293. assert all(schedule_1.loc[start + timedelta(hours=8) :] <= 0)
  294. # Case 2: Consumption Price = Production Price, roundtrip_efficiency = 1
  295. schedule_2, soc_schedule_2 = run_test_charge_discharge_sign(
  296. battery, 1, epex_da.id, epex_da.id
  297. )
  298. assert all(np.isclose(schedule_2[:8], 0)) # no oscillation
  299. # Case 3: Consumption Price > Production Price, roundtrip_efficiency < 1
  300. # In this case, we expect the battery to hold the energy that has initially and sell it during the period of
  301. # positive prices.
  302. schedule_3, soc_schedule_3 = run_test_charge_discharge_sign(
  303. battery, roundtrip_efficiency, epex_da.id, epex_da_production.id
  304. )
  305. assert all(np.isclose(schedule_3[:8], 0)) # no oscillation
  306. assert all(schedule_3[8:] <= 0)
  307. # discharge the whole battery in 1 time period
  308. capacity = battery.get_attribute(
  309. "capacity_in_mw",
  310. ur.Quantity(battery.get_attribute("site-power-capacity")).to("MW").magnitude,
  311. )
  312. assert np.isclose(
  313. schedule_3.min(),
  314. -capacity * np.sqrt(roundtrip_efficiency),
  315. )
  316. # Case 4: Consumption Price > Production Price, roundtrip_efficiency < 1
  317. schedule_4, soc_schedule_4 = run_test_charge_discharge_sign(
  318. battery, 1, epex_da.id, epex_da_production.id
  319. )
  320. assert all(np.isclose(schedule_4[:8], 0)) # no oscillation
  321. assert all(schedule_4[8:] <= 0)
  322. # discharge the whole battery in 1 time period, with no conversion losses
  323. assert np.isclose(schedule_4.min(), -capacity)
  324. @pytest.mark.parametrize(
  325. "target_soc, charging_station_name",
  326. [
  327. (1, "Test charging station"),
  328. (5, "Test charging station"),
  329. (0, "Test charging station (bidirectional)"),
  330. (5, "Test charging station (bidirectional)"),
  331. ],
  332. )
  333. def test_charging_station_solver_day_2(
  334. target_soc, charging_station_name, setup_planning_test_data, db
  335. ):
  336. """Starting with a state of charge 1 kWh, within 2 hours we should be able to reach
  337. any state of charge in the range [1, 5] kWh for a unidirectional station,
  338. or [0, 5] for a bidirectional station, given a charging capacity of 2 kW.
  339. """
  340. soc_at_start = 1
  341. duration_until_target = timedelta(hours=2)
  342. epex_da = get_test_sensor(db)
  343. charging_station = setup_planning_test_data[charging_station_name].sensors[0]
  344. capacity = charging_station.get_attribute(
  345. "capacity_in_mw",
  346. ur.Quantity(charging_station.get_attribute("site-power-capacity"))
  347. .to("MW")
  348. .magnitude,
  349. )
  350. assert capacity == 2
  351. assert charging_station.get_attribute("consumption-price") == {"sensor": epex_da.id}
  352. tz = pytz.timezone("Europe/Amsterdam")
  353. start = tz.localize(datetime(2015, 1, 2))
  354. end = tz.localize(datetime(2015, 1, 3))
  355. resolution = timedelta(minutes=15)
  356. target_soc_datetime = start + duration_until_target
  357. soc_targets = initialize_series(np.nan, start, end, resolution, inclusive="right")
  358. soc_targets.loc[target_soc_datetime] = target_soc
  359. scheduler = StorageScheduler(
  360. charging_station,
  361. start,
  362. end,
  363. resolution,
  364. flex_model={
  365. "soc_at_start": soc_at_start,
  366. "soc_min": charging_station.get_attribute("min_soc_in_mwh", 0),
  367. "soc_max": charging_station.get_attribute(
  368. "max_soc_in_mwh", max(soc_targets.values)
  369. ),
  370. "roundtrip_efficiency": charging_station.get_attribute(
  371. "roundtrip_efficiency", 1
  372. ),
  373. "storage_efficiency": charging_station.get_attribute(
  374. "storage_efficiency", 1
  375. ),
  376. "soc_targets": soc_targets,
  377. },
  378. flex_context={
  379. "ems_power_capacity_in_mw": ur.Quantity("2 MVA"),
  380. "consumption_price": epex_da,
  381. },
  382. )
  383. scheduler.config_deserialized = (
  384. True # soc targets are already a DataFrame, names get underscore
  385. )
  386. consumption_schedule = scheduler.compute()
  387. soc_schedule = integrate_time_series(
  388. consumption_schedule, soc_at_start, decimal_precision=6
  389. )
  390. # Check if constraints were met
  391. assert min(consumption_schedule.values) >= capacity * -1
  392. assert max(consumption_schedule.values) <= capacity + TOLERANCE
  393. print(consumption_schedule.head(12))
  394. print(soc_schedule.head(12))
  395. assert abs(soc_schedule.loc[target_soc_datetime] - target_soc) < TOLERANCE
  396. @pytest.mark.parametrize(
  397. "target_soc, charging_station_name",
  398. [
  399. (9, "Test charging station"),
  400. (15, "Test charging station"),
  401. (5, "Test charging station (bidirectional)"),
  402. (15, "Test charging station (bidirectional)"),
  403. ],
  404. )
  405. def test_fallback_to_unsolvable_problem(
  406. target_soc, charging_station_name, setup_planning_test_data, db
  407. ):
  408. """Starting with a state of charge 10 kWh, within 2 hours we should be able to reach
  409. any state of charge in the range [10, 14] kWh for a unidirectional station,
  410. or [6, 14] for a bidirectional station, given a charging capacity of 2 kW.
  411. Here we test target states of charge outside that range, ones that we should be able
  412. to get as close to as 1 kWh difference.
  413. We want our scheduler to handle unsolvable problems like these with a sensible fallback policy.
  414. The StorageScheduler raises an Exception which triggers the creation of a new job to compute a fallback
  415. schedule.
  416. """
  417. soc_at_start = 10
  418. duration_until_target = timedelta(hours=2)
  419. expected_gap = 1
  420. epex_da = get_test_sensor(db)
  421. charging_station = setup_planning_test_data[charging_station_name].sensors[0]
  422. capacity = charging_station.get_attribute(
  423. "capacity_in_mw",
  424. ur.Quantity(charging_station.get_attribute("site-power-capacity"))
  425. .to("MW")
  426. .magnitude,
  427. )
  428. assert capacity == 2
  429. assert charging_station.get_attribute("consumption-price") == {"sensor": epex_da.id}
  430. tz = pytz.timezone("Europe/Amsterdam")
  431. start = tz.localize(datetime(2015, 1, 2))
  432. end = tz.localize(datetime(2015, 1, 3))
  433. resolution = timedelta(minutes=15)
  434. target_soc_datetime = start + duration_until_target
  435. soc_targets = initialize_series(np.nan, start, end, resolution, inclusive="right")
  436. soc_targets.loc[target_soc_datetime] = target_soc
  437. kwargs = {
  438. "asset_or_sensor": charging_station,
  439. "start": start,
  440. "end": end,
  441. "resolution": resolution,
  442. "flex_model": {
  443. "soc_at_start": soc_at_start,
  444. "soc_min": charging_station.get_attribute("min_soc_in_mwh", 0),
  445. "soc_max": charging_station.get_attribute(
  446. "max_soc_in_mwh", max(soc_targets.values)
  447. ),
  448. "roundtrip_efficiency": charging_station.get_attribute(
  449. "roundtrip_efficiency", 1
  450. ),
  451. "storage_efficiency": charging_station.get_attribute(
  452. "storage_efficiency", 1
  453. ),
  454. "soc_targets": soc_targets,
  455. },
  456. "flex_context": {
  457. "ems_power_capacity_in_mw": ur.Quantity(f"{capacity} MVA"),
  458. "consumption_price": epex_da,
  459. },
  460. }
  461. scheduler = StorageScheduler(**kwargs)
  462. scheduler.config_deserialized = (
  463. True # soc targets are already a DataFrame, names get underscore
  464. )
  465. # calling the scheduler with an infeasible problem raises an Exception
  466. with pytest.raises(InfeasibleProblemException):
  467. consumption_schedule = scheduler.compute(skip_validation=True)
  468. # check that the fallback scheduler provides a sensible fallback policy
  469. fallback_scheduler = scheduler.fallback_scheduler_class(**kwargs)
  470. fallback_scheduler.config_deserialized = True
  471. consumption_schedule = fallback_scheduler.compute(skip_validation=True)
  472. soc_schedule = integrate_time_series(
  473. consumption_schedule, soc_at_start, decimal_precision=6
  474. )
  475. # Check if constraints were met
  476. assert min(consumption_schedule.values) >= capacity * -1
  477. assert max(consumption_schedule.values) <= capacity
  478. print(consumption_schedule.head(12))
  479. print(soc_schedule.head(12))
  480. assert (
  481. abs(abs(soc_schedule.loc[target_soc_datetime] - target_soc) - expected_gap)
  482. < TOLERANCE
  483. )
  484. @pytest.mark.parametrize(
  485. "market_scenario",
  486. [
  487. "dynamic contract",
  488. "fixed contract",
  489. ],
  490. )
  491. def test_building_solver_day_2(
  492. db,
  493. add_battery_assets,
  494. add_market_prices,
  495. create_test_tariffs,
  496. add_inflexible_device_forecasts,
  497. inflexible_devices,
  498. flexible_devices,
  499. market_scenario: str,
  500. ):
  501. """Check battery scheduling results within the context of a building with PV, for day 2, against the following market scenarios:
  502. 1) a dynamic tariff with equal consumption and feed-in tariffs, that is set up with 8 expensive, then 8 cheap, then again 8 expensive hours.
  503. 2) a fixed consumption tariff and a fixed feed-in tariff that is lower, which incentives to maximize self-consumption of PV power into the battery.
  504. In the test data:
  505. - Hours with net production coincide with low dynamic market prices.
  506. - Hours with net consumption coincide with high dynamic market prices.
  507. So when the prices are low (in scenario 1), we have net production, and when they are high, net consumption.
  508. That means we have first net consumption, then net production, and then net consumption again.
  509. In either scenario, we expect the scheduler to:
  510. - completely discharge within the first 8 hours (either due to 1) high prices, or 2) net consumption)
  511. - completely charge within the next 8 hours (either due to 1) low prices, or 2) net production)
  512. - completely discharge within the last 8 hours (either due to 1) high prices, or 2) net consumption)
  513. """
  514. battery = flexible_devices["battery power sensor"]
  515. building = battery.generic_asset
  516. default_consumption_price_sensor = get_test_sensor(db)
  517. assert battery.get_attribute("consumption-price") == {
  518. "sensor": default_consumption_price_sensor.id
  519. }
  520. if market_scenario == "dynamic contract":
  521. consumption_price_sensor = default_consumption_price_sensor
  522. production_price_sensor = consumption_price_sensor
  523. elif market_scenario == "fixed contract":
  524. consumption_price_sensor = create_test_tariffs["consumption_price_sensor"]
  525. production_price_sensor = create_test_tariffs["production_price_sensor"]
  526. else:
  527. raise NotImplementedError(
  528. f"Missing test case for market conditions '{market_scenario}'"
  529. )
  530. tz = pytz.timezone("Europe/Amsterdam")
  531. start = tz.localize(datetime(2015, 1, 2))
  532. end = tz.localize(datetime(2015, 1, 3))
  533. resolution = timedelta(minutes=15)
  534. soc_at_start = 2.5
  535. soc_min = 0.5
  536. soc_max = 4.5
  537. scheduler = StorageScheduler(
  538. battery,
  539. start,
  540. end,
  541. resolution,
  542. flex_model={
  543. "soc_at_start": soc_at_start,
  544. "soc_min": soc_min,
  545. "soc_max": soc_max,
  546. "roundtrip_efficiency": battery.get_attribute("roundtrip_efficiency", 1),
  547. "storage_efficiency": battery.get_attribute("storage_efficiency", 1),
  548. },
  549. flex_context={
  550. "ems_power_capacity_in_mw": ur.Quantity("2 MVA"),
  551. "inflexible_device_sensors": inflexible_devices.values(),
  552. "production_price": production_price_sensor,
  553. "consumption_price": consumption_price_sensor,
  554. },
  555. )
  556. scheduler.config_deserialized = (
  557. True # inflexible device sensors are already objects, names get underscore
  558. )
  559. schedule = scheduler.compute()
  560. soc_schedule = integrate_time_series(schedule, soc_at_start, decimal_precision=6)
  561. with pd.option_context("display.max_rows", None, "display.max_columns", 3):
  562. print(soc_schedule)
  563. unit_factors = np.expand_dims(
  564. [
  565. convert_units(1, s.unit, "MW")
  566. for s in add_inflexible_device_forecasts.keys()
  567. ],
  568. axis=1,
  569. )
  570. inflexible_devices_power = np.array(list(add_inflexible_device_forecasts.values()))
  571. # Check if constraints were met
  572. capacity = pd.DataFrame(
  573. data=inflexible_devices_power.T.dot(
  574. unit_factors
  575. ), # convert to MW and sum column-wise
  576. columns=["inflexible"],
  577. ).tail(
  578. -4 * 24
  579. ) # remove first 96 quarter-hours (the schedule is about the 2nd day)
  580. building_capacity = building.get_attribute(
  581. "capacity_in_mw",
  582. ur.Quantity(building.get_attribute("site-power-capacity")).to("MW").magnitude,
  583. )
  584. battery_capacity = battery.get_attribute(
  585. "capacity_in_mw"
  586. ) # actually a Sensor attribute
  587. capacity["max"] = building_capacity
  588. capacity["min"] = -building_capacity
  589. capacity["production headroom"] = capacity["max"] - capacity["inflexible"]
  590. capacity["consumption headroom"] = capacity["inflexible"] - capacity["min"]
  591. capacity["battery production headroom"] = capacity["production headroom"].clip(
  592. upper=battery_capacity
  593. )
  594. capacity["battery consumption headroom"] = capacity["consumption headroom"].clip(
  595. upper=battery_capacity
  596. )
  597. capacity["schedule"] = (
  598. schedule.values
  599. ) # consumption is positive, production is negative
  600. with pd.option_context(
  601. "display.max_rows", None, "display.max_columns", None, "display.width", 2000
  602. ):
  603. print(capacity)
  604. assert (capacity["schedule"] >= -capacity["battery production headroom"]).all()
  605. assert (capacity["schedule"] <= capacity["battery consumption headroom"]).all()
  606. for soc in soc_schedule.values:
  607. assert soc >= max(soc_min, battery.get_attribute("min_soc_in_mwh"))
  608. assert soc <= battery.get_attribute("max_soc_in_mwh")
  609. # Check whether the resulting soc schedule follows our expectations for.
  610. # To recap, in scenario 1 and 2, the schedule should mainly be influenced by:
  611. # 1) 8 expensive, 8 cheap and 8 expensive hours
  612. # 2) 8 net-consumption, 8 net-production and 8 net-consumption hours
  613. # Result after 8 hours
  614. # 1) Sell what you begin with
  615. # 2) The battery discharged as far as it could during the first 8 net-consumption hours
  616. assert soc_schedule.loc[start + timedelta(hours=8)] == max(
  617. soc_min, battery.get_attribute("min_soc_in_mwh")
  618. )
  619. # Result after second 8 hour-interval
  620. # 1) Buy what you can to sell later, when prices will be high again
  621. # 2) The battery charged with PV power as far as it could during the middle 8 net-production hours
  622. assert soc_schedule.loc[start + timedelta(hours=16)] == min(
  623. soc_max, battery.get_attribute("max_soc_in_mwh")
  624. )
  625. # Result at end of day
  626. # 1) The battery sold out at the end of its planning horizon
  627. # 2) The battery discharged as far as it could during the last 8 net-consumption hours
  628. assert soc_schedule.iloc[-1] == max(
  629. soc_min, battery.get_attribute("min_soc_in_mwh")
  630. )
  631. def test_soc_bounds_timeseries(db, add_battery_assets):
  632. """Check that the maxima and minima timeseries alter the result
  633. of the optimization.
  634. Two schedules are run:
  635. - with global maximum and minimum values
  636. - with global maximum and minimum values + maxima / minima time series constraints
  637. """
  638. # get the sensors from the database
  639. epex_da, battery = get_sensors_from_db(db, add_battery_assets)
  640. # time parameters
  641. tz = pytz.timezone("Europe/Amsterdam")
  642. start = tz.localize(datetime(2015, 1, 2))
  643. end = tz.localize(datetime(2015, 1, 3))
  644. resolution = timedelta(hours=1)
  645. # soc parameters
  646. soc_unit = "MWh"
  647. soc_at_start = battery.get_attribute("soc_in_mwh")
  648. soc_min = 0.5
  649. soc_max = 4.5
  650. def compute_schedule(flex_model):
  651. scheduler = StorageScheduler(
  652. battery,
  653. start,
  654. end,
  655. resolution,
  656. flex_model=flex_model,
  657. )
  658. schedule = scheduler.compute()
  659. soc_schedule = integrate_time_series(
  660. schedule,
  661. soc_at_start,
  662. decimal_precision=6,
  663. )
  664. return soc_schedule
  665. flex_model = {
  666. "soc-unit": soc_unit,
  667. "soc-at-start": soc_at_start,
  668. "soc-min": soc_min,
  669. "soc-max": soc_max,
  670. }
  671. soc_schedule_1 = compute_schedule(flex_model)
  672. # soc maxima and soc minima
  673. soc_maxima = [
  674. {"datetime": "2015-01-02T15:00:00+01:00", "value": 1.0},
  675. {"datetime": "2015-01-02T16:00:00+01:00", "value": 1.0},
  676. ]
  677. soc_minima = [{"datetime": "2015-01-02T08:00:00+01:00", "value": 3.5}]
  678. soc_targets = [{"datetime": "2015-01-02T19:00:00+01:00", "value": 2.0}]
  679. flex_model = {
  680. "soc-unit": soc_unit,
  681. "soc-at-start": soc_at_start,
  682. "soc-min": soc_min,
  683. "soc-max": soc_max,
  684. "soc-maxima": soc_maxima,
  685. "soc-minima": soc_minima,
  686. "soc-targets": soc_targets,
  687. }
  688. soc_schedule_2 = compute_schedule(flex_model)
  689. # check that, in this case, adding the constraints alters the SOC profile
  690. assert not soc_schedule_2.equals(soc_schedule_1)
  691. # check that global minimum is achieved
  692. assert soc_schedule_1.min() == soc_min
  693. assert soc_schedule_2.min() == soc_min
  694. # check that global maximum is achieved
  695. assert soc_schedule_1.max() == soc_max
  696. assert soc_schedule_2.max() == soc_max
  697. # test for soc_minima
  698. # check that the local minimum constraint is respected
  699. assert soc_schedule_2.loc["2015-01-02T08:00:00+01:00"] >= 3.5
  700. # test for soc_maxima
  701. # check that the local maximum constraint is respected
  702. assert soc_schedule_2.loc["2015-01-02T15:00:00+01:00"] <= 1.0
  703. # test for soc_targets
  704. # check that the SOC target (at 19 pm, local time) is met
  705. assert soc_schedule_2.loc["2015-01-02T19:00:00+01:00"] == 2.0
  706. @pytest.mark.parametrize(
  707. "value_soc_min, value_soc_minima, value_soc_target, value_soc_maxima, value_soc_max",
  708. [
  709. (-1, -0.5, 0, 0.5, 1.0),
  710. (-1, -2, 0, 0.5, 1.0),
  711. (-1, -0.5, 0.5, 0.5, 1.0),
  712. ],
  713. )
  714. def test_add_storage_constraints(
  715. value_soc_min, value_soc_minima, value_soc_target, value_soc_maxima, value_soc_max
  716. ):
  717. """Check that the storage constraints are generated properly"""
  718. # from 00:00 to 04.00, both inclusive.
  719. start = datetime(2023, 5, 18, tzinfo=pytz.utc)
  720. end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc)
  721. # hourly resolution
  722. resolution = timedelta(hours=1)
  723. soc_at_start = 0.0
  724. test_date = start + timedelta(hours=1)
  725. soc_targets = initialize_series(np.nan, start, end, resolution)
  726. soc_targets[test_date] = value_soc_target
  727. soc_maxima = initialize_series(np.nan, start, end, resolution)
  728. soc_maxima[test_date] = value_soc_maxima
  729. soc_minima = initialize_series(np.nan, start, end, resolution)
  730. soc_minima[test_date] = value_soc_minima
  731. soc_max = value_soc_max
  732. soc_min = value_soc_min
  733. storage_device_constraints = add_storage_constraints(
  734. start,
  735. end,
  736. resolution,
  737. soc_at_start,
  738. soc_targets,
  739. soc_maxima,
  740. soc_minima,
  741. soc_max,
  742. soc_min,
  743. )
  744. assert (storage_device_constraints["max"] <= soc_max).all()
  745. assert (storage_device_constraints["min"] >= soc_min).all()
  746. equals_not_nan = ~storage_device_constraints["equals"].isna()
  747. assert (storage_device_constraints["min"] <= storage_device_constraints["equals"])[
  748. equals_not_nan
  749. ].all()
  750. assert (storage_device_constraints["equals"] <= storage_device_constraints["max"])[
  751. equals_not_nan
  752. ].all()
  753. @pytest.mark.parametrize(
  754. "value_min1, value_equals1, value_max1, value_min2, value_equals2, value_max2, expected_constraint_type_violations",
  755. [
  756. (1, np.nan, 9, 1, np.nan, 9, []), # base case
  757. (1, np.nan, 10, 1, np.nan, 10, []), # exact equality
  758. (
  759. 1,
  760. np.nan,
  761. 10 + 0.5e-6,
  762. 1,
  763. np.nan,
  764. 10,
  765. [],
  766. ), # equality considering the precision (6 decimal figures)
  767. (
  768. 1,
  769. np.nan,
  770. 10 + 1e-5,
  771. 1,
  772. np.nan,
  773. 10,
  774. ["max(t) <= soc_max(t)"],
  775. ), # difference of 0.5e-5 > 1e-6
  776. (1, np.nan, 9, 2, np.nan, 20, ["max(t) <= soc_max(t)"]),
  777. (-1, np.nan, 9, 1, np.nan, 9, ["soc_min(t) <= min(t)"]),
  778. (1, 10, 9, 1, np.nan, 9, ["equals(t) <= max(t)"]),
  779. (1, 0, 9, 1, np.nan, 9, ["min(t) <= equals(t)"]),
  780. (
  781. 1,
  782. np.nan,
  783. 9,
  784. 9,
  785. np.nan,
  786. 1,
  787. ["min(t) <= max(t)"],
  788. ),
  789. (
  790. 9,
  791. 5,
  792. 1,
  793. 1,
  794. np.nan,
  795. 9,
  796. ["min(t) <= equals(t)", "equals(t) <= max(t)", "min(t) <= max(t)"],
  797. ),
  798. (1, np.nan, 9, 1, np.nan, 9, []), # same interval, should not fail
  799. (1, np.nan, 9, 3, np.nan, 7, []), # should not fail, containing interval
  800. (1, np.nan, 3, 3, np.nan, 5, []), # difference = 0 < 1, should not fail
  801. (1, np.nan, 3, 4, np.nan, 5, []), # difference == max, should not fail
  802. (
  803. 1,
  804. np.nan,
  805. 3,
  806. 5,
  807. np.nan,
  808. 7,
  809. ["min(t) - max(t-1) <= derivative_max(t) * factor_w_wh(t)"],
  810. ), # difference > max = 1, this should fail
  811. (3, np.nan, 5, 2, np.nan, 3, []), # difference = 0 < 1, should not fail
  812. (3, np.nan, 5, 1, np.nan, 2, []), # difference = -1 >= -1, should not fail
  813. (
  814. 3,
  815. np.nan,
  816. 5,
  817. 1,
  818. np.nan,
  819. 1,
  820. ["derivative_min(t) * factor_w_wh(t) <= max(t) - min(t-1)"],
  821. ), # difference = -2 < -1, should fail,
  822. (1, 4, 9, 1, 4, 9, []), # same target value (4), should not fail
  823. (
  824. 1,
  825. 6,
  826. 9,
  827. 1,
  828. 4,
  829. 9,
  830. ["derivative_min(t) * factor_w_wh(t) <= equals(t) - equals(t-1)"],
  831. ), # difference = -2 < -1, should fail,
  832. (
  833. 1,
  834. 4,
  835. 9,
  836. 1,
  837. 6,
  838. 9,
  839. ["equals(t) - equals(t-1) <= derivative_max(t) * factor_w_wh(t)"],
  840. ), # difference 2 > 1, should fail,
  841. ],
  842. )
  843. def test_validate_constraints(
  844. value_min1,
  845. value_equals1,
  846. value_max1,
  847. value_min2,
  848. value_equals2,
  849. value_max2,
  850. expected_constraint_type_violations,
  851. ):
  852. """Check the validation of constraints.
  853. Two consecutive SOC ranges are parametrized (min, equals, max) and the different conditions are tested.
  854. """
  855. # from 00:00 to 04.00, both inclusive.
  856. start = datetime(2023, 5, 18, tzinfo=pytz.utc)
  857. end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc)
  858. # hourly resolution
  859. resolution = timedelta(hours=1)
  860. columns = ["equals", "max", "min", "derivative max", "derivative min"]
  861. storage_device_constraints = initialize_df(columns, start, end, resolution)
  862. test_time = start + resolution * 2
  863. storage_device_constraints["min"] = 0
  864. storage_device_constraints["max"] = 10
  865. storage_device_constraints["derivative max"] = 1
  866. storage_device_constraints["derivative min"] = -1
  867. storage_device_constraints.loc[
  868. storage_device_constraints.index == test_time, "min"
  869. ] = value_min1
  870. storage_device_constraints.loc[
  871. storage_device_constraints.index == test_time, "max"
  872. ] = value_max1
  873. storage_device_constraints.loc[
  874. storage_device_constraints.index == test_time, "equals"
  875. ] = value_equals1
  876. storage_device_constraints.loc[
  877. storage_device_constraints.index == test_time + resolution, "min"
  878. ] = value_min2
  879. storage_device_constraints.loc[
  880. storage_device_constraints.index == test_time + resolution, "max"
  881. ] = value_max2
  882. storage_device_constraints.loc[
  883. storage_device_constraints.index == test_time + resolution, "equals"
  884. ] = value_equals2
  885. constraint_violations = validate_storage_constraints(
  886. constraints=storage_device_constraints,
  887. soc_at_start=0.0,
  888. soc_min=0,
  889. soc_max=10,
  890. resolution=resolution,
  891. )
  892. constraint_type_violations_output = set(
  893. constraint_violation["condition"]
  894. for constraint_violation in constraint_violations
  895. )
  896. assert set(expected_constraint_type_violations) == constraint_type_violations_output
  897. def test_infeasible_problem_error(db, add_battery_assets):
  898. """Try to create a schedule with infeasible constraints. soc-max is 4.5 and soc-target is 8.0"""
  899. # get the sensors from the database
  900. _epex_da, battery = get_sensors_from_db(db, add_battery_assets)
  901. # time parameters
  902. tz = pytz.timezone("Europe/Amsterdam")
  903. start = tz.localize(datetime(2015, 1, 2))
  904. end = tz.localize(datetime(2015, 1, 3))
  905. resolution = timedelta(hours=1)
  906. def compute_schedule(flex_model):
  907. scheduler = StorageScheduler(
  908. battery,
  909. start,
  910. end,
  911. resolution,
  912. flex_model=flex_model,
  913. )
  914. schedule = scheduler.compute()
  915. soc_schedule = integrate_time_series(
  916. schedule,
  917. soc_at_start,
  918. decimal_precision=1,
  919. )
  920. return soc_schedule
  921. # soc parameters
  922. soc_at_start = battery.get_attribute("soc_in_mwh")
  923. infeasible_max_soc_targets = [
  924. {"datetime": "2015-01-02T16:00:00+01:00", "value": 8.0}
  925. ]
  926. flex_model = {
  927. "soc-at-start": soc_at_start,
  928. "soc-min": 0.5,
  929. "soc-max": 4.5,
  930. "soc-targets": infeasible_max_soc_targets,
  931. }
  932. with pytest.raises(
  933. ValueError, match="The input data yields an infeasible problem."
  934. ):
  935. compute_schedule(flex_model)
  936. def test_numerical_errors(app_with_each_solver, setup_planning_test_data, db):
  937. """Test that a soc-target = soc-max can exceed this value due to numerical errors in the operations
  938. to compute the device constraint DataFrame.
  939. In the case of HiGHS, the tiny difference creates an infeasible constraint.
  940. """
  941. epex_da = get_test_sensor(db)
  942. charging_station = setup_planning_test_data[
  943. "Test charging station (bidirectional)"
  944. ].sensors[0]
  945. capacity = charging_station.get_attribute(
  946. "capacity_in_mw",
  947. ur.Quantity(charging_station.get_attribute("site-power-capacity"))
  948. .to("MW")
  949. .magnitude,
  950. )
  951. assert capacity == 2
  952. assert charging_station.get_attribute("consumption-price") == {"sensor": epex_da.id}
  953. tz = pytz.timezone("Europe/Amsterdam")
  954. start = tz.localize(datetime(2015, 1, 2))
  955. end = tz.localize(datetime(2015, 1, 3))
  956. resolution = timedelta(minutes=5)
  957. duration_until_next_target = timedelta(hours=1)
  958. target_soc_datetime_1 = pd.Timestamp(start + duration_until_next_target).isoformat()
  959. target_soc_datetime_2 = pd.Timestamp(
  960. start + 2 * duration_until_next_target
  961. ).isoformat()
  962. scheduler = StorageScheduler(
  963. charging_station,
  964. start,
  965. end,
  966. resolution,
  967. flex_model={
  968. "soc-at-start": 0.01456,
  969. "soc-min": 0.01295,
  970. "soc-max": 0.056,
  971. "roundtrip-efficiency": 0.85,
  972. "storage-efficiency": 1,
  973. "soc-targets": [
  974. {"value": 0.01295, "datetime": target_soc_datetime_1},
  975. {"value": 0.056, "datetime": target_soc_datetime_2},
  976. ],
  977. "soc-unit": "MWh",
  978. },
  979. )
  980. (
  981. sensors,
  982. start,
  983. end,
  984. resolution,
  985. soc_at_start,
  986. device_constraints,
  987. ems_constraints,
  988. commitments,
  989. ) = scheduler._prepare(skip_validation=True)
  990. _, _, results, model = device_scheduler(
  991. device_constraints=device_constraints,
  992. ems_constraints=ems_constraints,
  993. commitments=commitments,
  994. initial_stock=[
  995. soc_at_start_d * (timedelta(hours=1) / resolution)
  996. for soc_at_start_d in soc_at_start
  997. ],
  998. )
  999. assert results.solver.termination_condition == "optimal"
  1000. assert device_constraints[0]["equals"].max() > device_constraints[0]["max"].max()
  1001. assert device_constraints[0]["equals"].min() < device_constraints[0]["min"].min()
  1002. assert results.solver.status == "ok"
  1003. @pytest.mark.parametrize(
  1004. "power_sensor_name,capacity,expected_capacity,site_capacity,site_consumption_capacity,site_production_capacity,expected_site_consumption_capacity, expected_site_production_capacity",
  1005. [
  1006. (
  1007. "Battery (with symmetric site limits)",
  1008. "100kW",
  1009. 0.1,
  1010. "300kW",
  1011. None,
  1012. None,
  1013. 0.3,
  1014. -0.3,
  1015. ),
  1016. (
  1017. "Battery (with symmetric site limits)",
  1018. "0.2 MW",
  1019. 0.2,
  1020. "42 kW",
  1021. None,
  1022. None,
  1023. 0.042,
  1024. -0.042,
  1025. ),
  1026. (
  1027. "Battery (with symmetric site limits)",
  1028. "0.2 MW",
  1029. 0.2,
  1030. "42 kW",
  1031. "10kW",
  1032. "100kW",
  1033. 0.01,
  1034. -0.042,
  1035. ),
  1036. (
  1037. "Battery (with symmetric site limits)",
  1038. "0.2 MW",
  1039. 0.2,
  1040. None,
  1041. "10kW",
  1042. "100kW",
  1043. 0.01,
  1044. -0.1,
  1045. ),
  1046. (
  1047. "Battery (with symmetric site limits)",
  1048. "0.2 MW",
  1049. 0.2,
  1050. None,
  1051. None,
  1052. None,
  1053. 2,
  1054. -2,
  1055. ),
  1056. (
  1057. "Battery (with asymmetric site limits)",
  1058. "0.2 MW",
  1059. 0.2,
  1060. None,
  1061. None,
  1062. None,
  1063. 0.9,
  1064. -0.75,
  1065. ), # default from the asset attributes
  1066. (
  1067. "Battery (with asymmetric site limits)",
  1068. "0.2 MW",
  1069. 0.2,
  1070. "42 kW",
  1071. None,
  1072. None,
  1073. 0.042,
  1074. -0.042,
  1075. ),
  1076. (
  1077. "Battery (with asymmetric site limits)",
  1078. "0.2 MW",
  1079. 0.2,
  1080. "42 kW",
  1081. "30kW",
  1082. None,
  1083. 0.03,
  1084. -0.042,
  1085. ),
  1086. (
  1087. "Battery (with asymmetric site limits)",
  1088. "0.2 MW",
  1089. 0.2,
  1090. "42 kW",
  1091. None,
  1092. "30kW",
  1093. 0.042,
  1094. -0.03,
  1095. ),
  1096. ],
  1097. )
  1098. def test_capacity(
  1099. app,
  1100. power_sensor_name,
  1101. add_assets_with_site_power_limits,
  1102. add_market_prices,
  1103. capacity,
  1104. expected_capacity,
  1105. site_capacity,
  1106. site_consumption_capacity,
  1107. site_production_capacity,
  1108. expected_site_consumption_capacity,
  1109. expected_site_production_capacity,
  1110. ):
  1111. """Test that the power limits of the site and storage device are set properly using the
  1112. flex-model and flex-context.
  1113. Priority for fetching the production and consumption capacity:
  1114. "site-{direction}-capacity" (flex-context) -> "site-power-capacity" (flex-context) ->
  1115. "{direction}_capacity_in_mw" (asset attribute) -> "capacity_in_mw" (asset attribute)
  1116. direction = "production" or "consumption"
  1117. """
  1118. flex_model = {
  1119. "soc-at-start": 0.01,
  1120. }
  1121. flex_context = {
  1122. "production-price": {"sensor": add_market_prices["epex_da_production"].id},
  1123. "consumption-price": {"sensor": add_market_prices["epex_da"].id},
  1124. }
  1125. def set_if_not_none(dictionary, key, value):
  1126. if value is not None:
  1127. dictionary[key] = value
  1128. set_if_not_none(flex_model, "power-capacity", capacity)
  1129. set_if_not_none(flex_context, "site-power-capacity", site_capacity)
  1130. set_if_not_none(
  1131. flex_context, "site-consumption-capacity", site_consumption_capacity
  1132. )
  1133. set_if_not_none(flex_context, "site-production-capacity", site_production_capacity)
  1134. device = add_assets_with_site_power_limits[power_sensor_name]
  1135. tz = pytz.timezone("Europe/Amsterdam")
  1136. start = tz.localize(datetime(2015, 1, 3))
  1137. end = tz.localize(datetime(2015, 1, 4))
  1138. resolution = timedelta(minutes=5)
  1139. scheduler = StorageScheduler(
  1140. device,
  1141. start,
  1142. end,
  1143. resolution,
  1144. flex_model=flex_model,
  1145. flex_context=flex_context,
  1146. )
  1147. (
  1148. sensors,
  1149. start,
  1150. end,
  1151. resolution,
  1152. soc_at_start,
  1153. device_constraints,
  1154. ems_constraints,
  1155. commitments,
  1156. ) = scheduler._prepare(skip_validation=True)
  1157. assert all(device_constraints[0]["derivative min"] == -expected_capacity)
  1158. assert all(device_constraints[0]["derivative max"] == expected_capacity)
  1159. assert all(ems_constraints["derivative min"] == expected_site_production_capacity)
  1160. assert all(ems_constraints["derivative max"] == expected_site_consumption_capacity)
  1161. @pytest.mark.parametrize(
  1162. ["soc_values", "log_message", "expected_num_targets"],
  1163. [
  1164. (
  1165. [
  1166. {
  1167. "start": datetime(2023, 5, 19, tzinfo=pytz.utc),
  1168. "end": datetime(2023, 5, 23, tzinfo=pytz.utc),
  1169. "value": 1.0,
  1170. },
  1171. ],
  1172. "Disregarding target datetimes that exceed 2023-05-20 00:00:00+00:00 (within the window 2023-05-19 00:00:00+00:00 until 2023-05-23 00:00:00+00:00).",
  1173. 1 * 24 * 60 / 5
  1174. + 1, # every 5-minute mark on the 19th including both midnights
  1175. ),
  1176. (
  1177. [
  1178. {"datetime": datetime(2023, 5, 19, tzinfo=pytz.utc), "value": 1.0},
  1179. {"datetime": datetime(2023, 5, 23, tzinfo=pytz.utc), "value": 1.0},
  1180. ],
  1181. "Disregarding target datetime 2023-05-23 00:00:00+00:00, because it exceeds 2023-05-20 00:00:00+00:00.",
  1182. 1, # only the 19th
  1183. ),
  1184. (
  1185. [
  1186. {"datetime": datetime(2023, 5, 19, tzinfo=pytz.utc), "value": 1.0},
  1187. {"datetime": datetime(2023, 5, 22, tzinfo=pytz.utc), "value": 1.0},
  1188. {"datetime": datetime(2023, 5, 23, tzinfo=pytz.utc), "value": 1.0},
  1189. {"datetime": datetime(2023, 5, 21, tzinfo=pytz.utc), "value": 1.0},
  1190. ],
  1191. "Disregarding target datetimes that exceed 2023-05-20 00:00:00+00:00 (within the window 2023-05-21 00:00:00+00:00 until 2023-05-23 00:00:00+00:00 spanning 3 targets).",
  1192. 1, # only the 19th
  1193. ),
  1194. ],
  1195. )
  1196. def test_build_device_soc_values(caplog, soc_values, log_message, expected_num_targets):
  1197. caplog.set_level(logging.WARNING)
  1198. soc_at_start = 3.0
  1199. start_of_schedule = datetime(2023, 5, 18, tzinfo=pytz.utc)
  1200. end_of_schedule = datetime(2023, 5, 20, tzinfo=pytz.utc)
  1201. resolution = timedelta(minutes=5)
  1202. # Convert SoC datetimes to periods with a start and end.
  1203. for soc in soc_values:
  1204. TimedEventSchema().check_time_window(soc)
  1205. with caplog.at_level(logging.WARNING):
  1206. device_values = build_device_soc_values(
  1207. soc_values=soc_values,
  1208. soc_at_start=soc_at_start,
  1209. start_of_schedule=start_of_schedule,
  1210. end_of_schedule=end_of_schedule,
  1211. resolution=resolution,
  1212. )
  1213. print(device_values)
  1214. assert log_message in caplog.text
  1215. # Check test assumption
  1216. for soc in soc_values:
  1217. assert soc["value"] == 1
  1218. soc_delta = 1 - soc_at_start
  1219. soc_delta_per_resolution = soc_delta * timedelta(hours=1) / resolution
  1220. assert soc_delta_per_resolution in device_values
  1221. assert np.count_nonzero(~np.isnan(device_values)) == expected_num_targets
  1222. @pytest.mark.parametrize(
  1223. [
  1224. "battery_name",
  1225. "production_sensor",
  1226. "consumption_sensor",
  1227. "production_quantity",
  1228. "consumption_quantity",
  1229. "expected_production",
  1230. "expected_consumption",
  1231. "site_production_capacity_sensor",
  1232. "site_consumption_capacity_sensor",
  1233. "expected_site_production",
  1234. "expected_site_consumption",
  1235. ],
  1236. [
  1237. (
  1238. "Test battery with dynamic power capacity",
  1239. False,
  1240. False,
  1241. None,
  1242. None,
  1243. -8, # from the power sensor attribute 'production_capacity'
  1244. 0.5, # from the power sensor attribute 'consumption_capacity'
  1245. False,
  1246. False,
  1247. -1.1,
  1248. 1.1,
  1249. ),
  1250. (
  1251. "Test battery with dynamic power capacity",
  1252. True,
  1253. False,
  1254. None,
  1255. None,
  1256. # from the flex model field 'production-capacity' (a sensor),
  1257. # and when absent, defaulting to the max value from the power sensor attribute capacity_in_mw
  1258. [-0.2] * 4 * 4 + [-0.3] * 4 * 4 + [-10] * 16 * 4,
  1259. # from the power sensor attribute 'consumption_capacity'
  1260. 0.5,
  1261. False,
  1262. False,
  1263. -1.1,
  1264. 1.1,
  1265. ),
  1266. (
  1267. "Test battery with dynamic power capacity",
  1268. False,
  1269. True,
  1270. None,
  1271. None,
  1272. # from the power sensor attribute 'consumption_capacity'
  1273. [-8] * 24 * 4,
  1274. # from the flex model field 'consumption-capacity' (a sensor),
  1275. # and when absent, defaulting to the max value from the power sensor attribute capacity_in_mw
  1276. [0.25] * 4 * 4 + [0.15] * 4 * 4 + [10] * 16 * 4,
  1277. False,
  1278. True,
  1279. -1.1,
  1280. # the first period with consumption capacity of 1.3 MVA is clipped by a site-power-capacity of 1.1 MVA,
  1281. # the second period with consumption capacity of 1.05 MVA is kept as is, and
  1282. # the third period with missing consumption capacity defaults to 1.1 MVA
  1283. [1.1] * 4 * 4 + [1.05] * 4 * 4 + [1.1] * 16 * 4,
  1284. ),
  1285. (
  1286. "Test battery with dynamic power capacity",
  1287. False,
  1288. False,
  1289. "100 kW",
  1290. "200 kW",
  1291. # from the flex model field 'production-capacity' (a quantity)
  1292. -0.1,
  1293. # from the flex model field 'consumption-capacity' (a quantity)
  1294. 0.2,
  1295. False,
  1296. False,
  1297. -1.1,
  1298. 1.1,
  1299. ),
  1300. (
  1301. "Test battery with dynamic power capacity",
  1302. False,
  1303. False,
  1304. "1 MW",
  1305. "2 MW",
  1306. # from the flex model field 'production-capacity' (a quantity)
  1307. -1,
  1308. # from the power sensor attribute 'consumption_capacity' (a quantity)
  1309. 2,
  1310. False,
  1311. False,
  1312. -1.1,
  1313. 1.1,
  1314. ),
  1315. (
  1316. "Test battery",
  1317. False,
  1318. False,
  1319. None,
  1320. None,
  1321. # from the asset attribute 'capacity_in_mw'
  1322. -2,
  1323. # from the asset attribute 'capacity_in_mw'
  1324. 2,
  1325. False,
  1326. False,
  1327. -1.1,
  1328. 1.1,
  1329. ),
  1330. (
  1331. "Test battery",
  1332. False,
  1333. False,
  1334. "10 kW",
  1335. None,
  1336. # from the flex model field 'production-capacity' (a quantity)
  1337. -0.01,
  1338. # from the asset attribute 'capacity_in_mw'
  1339. 2,
  1340. False,
  1341. False,
  1342. -1.1,
  1343. 1.1,
  1344. ),
  1345. (
  1346. "Test battery",
  1347. False,
  1348. False,
  1349. "10 kW",
  1350. "100 kW",
  1351. # from the flex model field 'production-capacity' (a quantity)
  1352. -0.01,
  1353. # from the flex model field 'consumption-capacity' (a quantity)
  1354. 0.1,
  1355. True,
  1356. True,
  1357. [-1.1] * 4 * 4 + [-1.05] * 4 * 4 + [-1.1] * 16 * 4,
  1358. [1.1] * 4 * 4 + [1.05] * 4 * 4 + [1.1] * 16 * 4,
  1359. ),
  1360. ],
  1361. )
  1362. def test_battery_power_capacity_as_sensor(
  1363. db,
  1364. add_battery_assets,
  1365. add_inflexible_device_forecasts,
  1366. capacity_sensors,
  1367. battery_name,
  1368. production_sensor,
  1369. consumption_sensor,
  1370. production_quantity,
  1371. consumption_quantity,
  1372. expected_production,
  1373. expected_consumption,
  1374. site_consumption_capacity_sensor,
  1375. site_production_capacity_sensor,
  1376. expected_site_production,
  1377. expected_site_consumption,
  1378. ):
  1379. epex_da, battery = get_sensors_from_db(
  1380. db, add_battery_assets, battery_name=battery_name
  1381. )
  1382. tz = pytz.timezone("Europe/Amsterdam")
  1383. start = tz.localize(datetime(2015, 1, 2))
  1384. end = tz.localize(datetime(2015, 1, 3))
  1385. resolution = timedelta(minutes=15)
  1386. soc_at_start = 10
  1387. flex_context = {"site-power-capacity": "1100 kVA"} # 1.1 MW
  1388. if site_consumption_capacity_sensor:
  1389. flex_context["site-consumption-capacity"] = {
  1390. "sensor": capacity_sensors["site_power_capacity"].id
  1391. }
  1392. if site_production_capacity_sensor:
  1393. flex_context["site-production-capacity"] = {
  1394. "sensor": capacity_sensors["site_power_capacity"].id
  1395. }
  1396. flex_model = {
  1397. "soc-at-start": soc_at_start,
  1398. "roundtrip-efficiency": "100%",
  1399. "prefer-charging-sooner": False,
  1400. }
  1401. if production_sensor:
  1402. flex_model["production-capacity"] = {
  1403. "sensor": capacity_sensors["production"].id
  1404. }
  1405. if consumption_sensor:
  1406. flex_model["consumption-capacity"] = {
  1407. "sensor": capacity_sensors["consumption"].id
  1408. }
  1409. if production_quantity is not None:
  1410. flex_model["production-capacity"] = production_quantity
  1411. if consumption_quantity is not None:
  1412. flex_model["consumption-capacity"] = consumption_quantity
  1413. scheduler: Scheduler = StorageScheduler(
  1414. battery,
  1415. start,
  1416. end,
  1417. resolution,
  1418. flex_model=flex_model,
  1419. flex_context=flex_context,
  1420. )
  1421. data_to_solver = scheduler._prepare()
  1422. device_constraints = data_to_solver[5][0]
  1423. ems_constraints = data_to_solver[6]
  1424. assert all(device_constraints["derivative min"].values == expected_production)
  1425. assert all(device_constraints["derivative max"].values == expected_consumption)
  1426. assert all(ems_constraints["derivative min"].values == expected_site_production)
  1427. assert all(ems_constraints["derivative max"].values == expected_site_consumption)
  1428. def test_battery_bothways_power_capacity_as_sensor(
  1429. db, add_battery_assets, add_inflexible_device_forecasts, capacity_sensors
  1430. ):
  1431. """Check that the charging and discharging power capacities are limited by the power capacity."""
  1432. epex_da, battery = get_sensors_from_db(
  1433. db, add_battery_assets, battery_name="Test battery"
  1434. )
  1435. tz = pytz.timezone("Europe/Amsterdam")
  1436. start = tz.localize(datetime(2015, 1, 2))
  1437. end = tz.localize(datetime(2015, 1, 2, 7, 45))
  1438. resolution = timedelta(minutes=15)
  1439. soc_at_start = 10
  1440. flex_model = {
  1441. "soc-at-start": soc_at_start,
  1442. "roundtrip-efficiency": "100%",
  1443. "prefer-charging-sooner": False,
  1444. }
  1445. flex_model["power-capacity"] = {"sensor": capacity_sensors["production"].id}
  1446. flex_model["consumption-capacity"] = {"sensor": capacity_sensors["consumption"].id}
  1447. flex_model["production-capacity"] = {
  1448. "sensor": capacity_sensors["power_capacity"].id
  1449. }
  1450. scheduler: Scheduler = StorageScheduler(
  1451. battery, start, end, resolution, flex_model=flex_model
  1452. )
  1453. data_to_solver = scheduler._prepare()
  1454. device_constraints = data_to_solver[5][0]
  1455. max_capacity = (
  1456. capacity_sensors["power_capacity"]
  1457. .search_beliefs(event_starts_after=start, event_ends_before=end)
  1458. .event_value.values
  1459. )
  1460. assert all(device_constraints["derivative min"].values >= -max_capacity)
  1461. assert all(device_constraints["derivative max"].values <= max_capacity)
  1462. def get_efficiency_problem_device_constraints(
  1463. extra_flex_model, efficiency_sensors, add_battery_assets, db
  1464. ) -> pd.DataFrame:
  1465. _, battery = get_sensors_from_db(db, add_battery_assets)
  1466. tz = pytz.timezone("Europe/Amsterdam")
  1467. start = tz.localize(datetime(2015, 1, 1))
  1468. end = tz.localize(datetime(2015, 1, 2))
  1469. resolution = timedelta(minutes=15)
  1470. base_flex_model = {
  1471. "soc-max": 2,
  1472. "soc-min": 0,
  1473. }
  1474. base_flex_model.update(extra_flex_model)
  1475. scheduler: Scheduler = StorageScheduler(
  1476. battery, start, end, resolution, flex_model=base_flex_model
  1477. )
  1478. scheduler_data = scheduler._prepare()
  1479. return scheduler_data[5][0]
  1480. def test_dis_charging_efficiency_as_sensor(
  1481. db,
  1482. add_battery_assets,
  1483. add_inflexible_device_forecasts,
  1484. add_market_prices,
  1485. efficiency_sensors,
  1486. ):
  1487. """
  1488. Check that the charging and discharging efficiency can be defined in the flex-model.
  1489. For missing values, the fallback value is 100%.
  1490. """
  1491. tz = pytz.timezone("Europe/Amsterdam")
  1492. end = tz.localize(datetime(2015, 1, 2))
  1493. resolution = timedelta(minutes=15)
  1494. extra_flex_model = {
  1495. "charging-efficiency": {"sensor": efficiency_sensors["efficiency"].id},
  1496. "discharging-efficiency": "200%",
  1497. }
  1498. device_constraints = get_efficiency_problem_device_constraints(
  1499. extra_flex_model, efficiency_sensors, add_battery_assets, db
  1500. )
  1501. assert all(
  1502. device_constraints[: end - timedelta(hours=1) - resolution][
  1503. "derivative up efficiency"
  1504. ]
  1505. == 0.9
  1506. )
  1507. assert all(
  1508. device_constraints[end - timedelta(hours=1) :]["derivative up efficiency"] == 1
  1509. )
  1510. assert all(device_constraints["derivative down efficiency"] == 2)
  1511. @pytest.mark.parametrize(
  1512. "stock_delta_sensor",
  1513. ["delta fails", "delta", "delta hourly", "delta 5min", None],
  1514. )
  1515. def test_battery_stock_delta_sensor(
  1516. add_battery_assets, add_stock_delta, stock_delta_sensor, db
  1517. ):
  1518. """
  1519. Test the SOC delta feature using sensors.
  1520. An empty battery is made to fulfill a usage signal under a flat tariff.
  1521. The battery is only allowed to charge (production-capacity = 0).
  1522. Set up the same constant delta (capacity_in_mw) in different resolutions.
  1523. The problem is defined with the following settings:
  1524. - Battery empty at the start of the schedule (soc-at-start = 0).
  1525. - Battery of size 2 MWh.
  1526. - Consumption capacity of the battery is 2 MW.
  1527. - The battery cannot discharge.
  1528. With these settings, the battery needs to charge at a power or greater than the usage forecast
  1529. to keep the SOC within bounds ([0, 2 MWh]).
  1530. """
  1531. _, battery = get_sensors_from_db(db, add_battery_assets)
  1532. tz = pytz.timezone("Europe/Amsterdam")
  1533. start = tz.localize(datetime(2015, 1, 1))
  1534. end = tz.localize(datetime(2015, 1, 2))
  1535. resolution = timedelta(minutes=15)
  1536. if stock_delta_sensor is not None:
  1537. stock_delta_sensor_obj = add_stock_delta[stock_delta_sensor]
  1538. else:
  1539. stock_delta_sensor_obj = add_stock_delta["delta"]
  1540. capacity = stock_delta_sensor_obj.get_attribute(
  1541. "capacity_in_mw",
  1542. ur.Quantity(stock_delta_sensor_obj.get_attribute("site-power-capacity"))
  1543. .to("MW")
  1544. .magnitude,
  1545. )
  1546. flex_model = {
  1547. "soc-max": 2,
  1548. "soc-min": 0,
  1549. "roundtrip-efficiency": 1,
  1550. "storage-efficiency": 1,
  1551. "production-capacity": "0kW",
  1552. "soc-at-start": 0,
  1553. }
  1554. if stock_delta_sensor is not None:
  1555. flex_model = {
  1556. **flex_model,
  1557. **{"soc-usage": [{"sensor": stock_delta_sensor_obj.id}]},
  1558. }
  1559. scheduler: Scheduler = StorageScheduler(
  1560. battery,
  1561. start,
  1562. end,
  1563. resolution,
  1564. flex_model=flex_model,
  1565. )
  1566. if stock_delta_sensor == "delta fails":
  1567. with pytest.raises(InfeasibleProblemException):
  1568. scheduler.compute()
  1569. elif stock_delta_sensor is None:
  1570. # No usage -> the battery does not charge
  1571. schedule = scheduler.compute()
  1572. assert all(schedule == 0)
  1573. else:
  1574. # Some usage -> the battery needs to charge
  1575. schedule = scheduler.compute()
  1576. assert all(schedule == capacity)
  1577. @pytest.mark.parametrize(
  1578. "gain,usage,expected_delta",
  1579. [
  1580. (["1 MW"], ["1MW"], 0), # delta stock is 0 (1 MW - 1 MW)
  1581. (["0.5 MW", "0.5 MW"], None, 1), # 1 MW stock gain
  1582. (["100 kW"], None, 0.1), # 100 MW stock gain
  1583. (None, ["100 kW"], -0.1), # 100 kW stock usage
  1584. (None, None, 0), # no gain/usage defined -> the fallback usage is set to 0
  1585. ],
  1586. )
  1587. def test_battery_stock_delta_quantity(
  1588. add_battery_assets, gain, usage, expected_delta, db
  1589. ):
  1590. """
  1591. Test the stock gain field when a constant value is provided.
  1592. We expect a constant gain/usage to happen in every time period equal to the energy
  1593. value provided.
  1594. """
  1595. _, battery = get_sensors_from_db(db, add_battery_assets)
  1596. tz = pytz.timezone("Europe/Amsterdam")
  1597. start = tz.localize(datetime(2015, 1, 1))
  1598. end = tz.localize(datetime(2015, 1, 2))
  1599. resolution = timedelta(minutes=15)
  1600. flex_model = {
  1601. "soc-max": 2,
  1602. "soc-min": 0,
  1603. "roundtrip-efficiency": 1,
  1604. "storage-efficiency": 1,
  1605. }
  1606. if gain is not None:
  1607. flex_model["soc-gain"] = gain
  1608. if usage is not None:
  1609. flex_model["soc-usage"] = usage
  1610. scheduler: Scheduler = StorageScheduler(
  1611. battery, start, end, resolution, flex_model=flex_model
  1612. )
  1613. scheduler_info = scheduler._prepare()
  1614. if expected_delta is not None:
  1615. assert all(scheduler_info[5][0]["stock delta"] == expected_delta)
  1616. else:
  1617. assert all(scheduler_info[5][0]["stock delta"].isna())
  1618. @pytest.mark.parametrize(
  1619. "efficiency,expected_efficiency",
  1620. [
  1621. ("100%", 1),
  1622. (
  1623. "110%",
  1624. 1,
  1625. ), # value exceeding the upper bound (100%). It clips the values above 100%.
  1626. (
  1627. "90%",
  1628. 0.9,
  1629. ), # percentage unit to dimensionless units within the [0,1] interval
  1630. (0.9, 0.9), # numeric values are interpreted as dimensionless
  1631. (
  1632. None,
  1633. None,
  1634. ), # if the `storage-efficiency` is not defined, the constraint dataframe
  1635. # uses NaN.
  1636. ],
  1637. )
  1638. def test_battery_efficiency_quantity(
  1639. add_battery_assets, efficiency, expected_efficiency, db
  1640. ):
  1641. """
  1642. Test to ensure correct handling of storage efficiency quantities in the StorageScheduler.
  1643. The test covers the handling of percentage values, dimensionless numeric values, and the
  1644. case where the efficiency is not defined.
  1645. """
  1646. _, battery = get_sensors_from_db(db, add_battery_assets)
  1647. tz = pytz.timezone("Europe/Amsterdam")
  1648. start = tz.localize(datetime(2015, 1, 1))
  1649. end = tz.localize(datetime(2015, 1, 2))
  1650. resolution = timedelta(minutes=15)
  1651. flex_model = {
  1652. "soc-max": 2,
  1653. "soc-min": 0,
  1654. "roundtrip-efficiency": 1,
  1655. }
  1656. if efficiency is not None:
  1657. flex_model["storage-efficiency"] = efficiency
  1658. scheduler: Scheduler = StorageScheduler(
  1659. battery, start, end, resolution, flex_model=flex_model
  1660. )
  1661. scheduler_info = scheduler._prepare()
  1662. if efficiency is not None:
  1663. assert all(scheduler_info[5][0]["efficiency"] == expected_efficiency)
  1664. else:
  1665. assert all(scheduler_info[5][0]["efficiency"].isna())
  1666. @pytest.mark.parametrize(
  1667. "efficiency_sensor_name, expected_efficiency",
  1668. [
  1669. ("storage efficiency 90%", 0.9), # regular value
  1670. ("storage efficiency 110%", 1), # clip values that exceed 100%
  1671. ("storage efficiency negative", 0), # clip negative values
  1672. pytest.param(
  1673. "storage efficiency hourly",
  1674. 0.974003,
  1675. marks=pytest.mark.xfail(
  1676. reason="resampling storage efficiency is not supported"
  1677. ),
  1678. ), # this one fails.
  1679. # We should resample making sure that the efficiencies are equivalent.
  1680. # For example, 90% defined in 1h is equivalent to 97% in a 15min period (0.97^4≈0.9).
  1681. # Plans to support resampling efficiencies can be found here https://github.com/FlexMeasures/flexmeasures/issues/720
  1682. ],
  1683. )
  1684. def test_battery_storage_efficiency_sensor(
  1685. add_battery_assets,
  1686. add_storage_efficiency,
  1687. efficiency_sensor_name,
  1688. expected_efficiency,
  1689. db,
  1690. ):
  1691. """
  1692. Test the handling of different storage efficiency sensors in the StorageScheduler.
  1693. It checks if the scheduler correctly handles regular values, values exceeding 100%, negative values,
  1694. and values with different resolutions compared to the scheduling resolution.
  1695. """
  1696. _, battery = get_sensors_from_db(db, add_battery_assets)
  1697. tz = pytz.timezone("Europe/Amsterdam")
  1698. start = tz.localize(datetime(2015, 1, 1))
  1699. end = tz.localize(datetime(2015, 1, 2))
  1700. resolution = timedelta(minutes=15)
  1701. storage_efficiency_sensor_obj = add_storage_efficiency[efficiency_sensor_name]
  1702. scheduler: Scheduler = StorageScheduler(
  1703. battery,
  1704. start,
  1705. end,
  1706. resolution,
  1707. flex_model={
  1708. "soc-max": 2,
  1709. "soc-min": 0,
  1710. "roundtrip-efficiency": 1,
  1711. "storage-efficiency": {"sensor": storage_efficiency_sensor_obj.id},
  1712. "production-capacity": "0kW",
  1713. "soc-at-start": 0,
  1714. },
  1715. )
  1716. scheduler_info = scheduler._prepare()
  1717. assert all(scheduler_info[5][0]["efficiency"] == expected_efficiency)
  1718. @pytest.mark.parametrize(
  1719. "sensor_name, expected_start, expected_end, n_constraints",
  1720. [
  1721. # A value defined in a coarser resolution is upsampled to match the power sensor resolution.
  1722. (
  1723. "soc-targets (1h)",
  1724. "14:00:00",
  1725. "15:00:00",
  1726. 5,
  1727. ),
  1728. # A value defined in a finer resolution is downsampled to match the power sensor resolution.
  1729. # Only a single value coincides with the power sensor resolution.
  1730. pytest.param(
  1731. "soc-targets (5min)",
  1732. "14:00:00",
  1733. "14:00:00", # not "14:15:00"
  1734. 1,
  1735. marks=pytest.mark.xfail(
  1736. reason="timely-beliefs doesn't yet make it possible to resample to a certain frequency and event resolution simultaneously"
  1737. ),
  1738. ),
  1739. # A simple case, SOC constraint sensor in the same resolution as the power sensor.
  1740. (
  1741. "soc-targets (15min)",
  1742. "14:00:00",
  1743. "14:15:00",
  1744. 2,
  1745. ),
  1746. # For an instantaneous sensor, the value is set to the interval containing the instantaneous event.
  1747. (
  1748. "soc-targets (instantaneous)",
  1749. "14:00:00",
  1750. "14:00:00",
  1751. 1,
  1752. ),
  1753. # This is an event at 14:05:00 with a duration of 15min.
  1754. # This constraint should span the intervals from 14:00 to 14:15 and from 14:15 to 14:30, but we are not reindexing properly.
  1755. pytest.param(
  1756. "soc-targets (15min lagged)",
  1757. "14:00:00",
  1758. "14:15:00",
  1759. 1,
  1760. marks=pytest.mark.xfail(
  1761. reason="we should re-index the series so that values of the original index that overlap are used."
  1762. ),
  1763. ),
  1764. ],
  1765. )
  1766. def test_add_storage_constraint_from_sensor(
  1767. add_battery_assets,
  1768. add_soc_targets,
  1769. sensor_name,
  1770. expected_start,
  1771. expected_end,
  1772. n_constraints,
  1773. db,
  1774. ):
  1775. """
  1776. Test the handling of different values for the target SOC constraints as sensors in the StorageScheduler.
  1777. """
  1778. _, battery = get_sensors_from_db(db, add_battery_assets)
  1779. tz = pytz.timezone("Europe/Amsterdam")
  1780. start = tz.localize(datetime(2015, 1, 1))
  1781. end = tz.localize(datetime(2015, 1, 2))
  1782. resolution = timedelta(minutes=15)
  1783. soc_targets = add_soc_targets[sensor_name]
  1784. soc_at_start = 0
  1785. flex_model = {
  1786. "soc-max": 2,
  1787. "soc-min": 0,
  1788. "roundtrip-efficiency": 1,
  1789. "production-capacity": "0kW",
  1790. "soc-at-start": soc_at_start,
  1791. }
  1792. flex_model["soc-targets"] = {"sensor": soc_targets.id}
  1793. flex_model["soc-maxima"] = [
  1794. {
  1795. "datetime": "2015-01-01T13:45:00+01:00",
  1796. "value": "0.4 MWh",
  1797. }
  1798. ]
  1799. scheduler: Scheduler = StorageScheduler(
  1800. battery, start, end, resolution, flex_model=flex_model
  1801. )
  1802. scheduler_info = scheduler._prepare()
  1803. storage_constraints = scheduler_info[5][0]
  1804. # Start (date) + start (time) - resolution (due to device_constraints indexing states by the start of their preceding time slot)
  1805. expected_target_start = start + pd.Timedelta(expected_start) - resolution
  1806. expected_target_end = start + pd.Timedelta(expected_end) - resolution
  1807. expected_soc_target_value = 0.5 * timedelta(hours=1) / resolution
  1808. # convert dates from UTC to local time (Europe/Amsterdam)
  1809. equals = storage_constraints["equals"].tz_convert(tz)
  1810. # check that no value before expected_target_start is non-nan
  1811. assert all(equals[: expected_target_start - resolution].isna())
  1812. # check that no value after expected_target_end is non-nan
  1813. assert all(equals[expected_target_end + resolution :].isna())
  1814. # check that the values in the (expected_target_start, expected_target_end) are equal to the expected value
  1815. assert all(
  1816. equals[expected_target_start + resolution : expected_target_end]
  1817. == expected_soc_target_value
  1818. )
  1819. # Check the resulting schedule against the constraints
  1820. consumption_schedule = scheduler.compute()
  1821. soc_schedule = integrate_time_series(
  1822. consumption_schedule, soc_at_start, decimal_precision=6
  1823. )
  1824. # Note the equality constraints are shifted back to account for how they define the index to denote
  1825. # the start of the event that ends in the given equality state, whereas the index of the soc_schedule
  1826. # denotes the exact time of the given SoC state
  1827. comparison_df = pd.concat([equals.shift(1), soc_schedule], axis=1).dropna()
  1828. assert (
  1829. len(comparison_df)
  1830. ) == n_constraints, f"we expect {n_constraints} device constraints"
  1831. assert all(
  1832. comparison_df.iloc[:, 0] == comparison_df.iloc[:, 1] * 4
  1833. ), "the device constraint values should be 1/4th of the actual SoC values, due to the 15-minute resolution of the battery's power sensor"
  1834. def test_soc_maxima_minima_targets(db, add_battery_assets, soc_sensors):
  1835. """
  1836. Check that the SOC maxima, minima and targets can be defined as sensors in the StorageScheduler.
  1837. The SOC is forced to follow a certain trajectory both by means of the SOC target and by setting SOC maxima = SOC minima = SOC targets.
  1838. Moreover, the SOC maxima constraints are defined in MWh to check that the unit conversion works well.
  1839. """
  1840. power = add_battery_assets["Test battery with dynamic power capacity"].sensors[0]
  1841. epex_da = get_test_sensor(db)
  1842. soc_maxima, soc_minima, soc_targets, expected_soc_schedule = soc_sensors
  1843. tz = pytz.timezone("Europe/Amsterdam")
  1844. start = tz.localize(datetime(2015, 1, 1))
  1845. end = tz.localize(datetime(2015, 1, 2))
  1846. resolution = timedelta(minutes=15)
  1847. soc_at_start = 0.0
  1848. soc_max = 10
  1849. soc_min = 0
  1850. flex_model = {
  1851. "soc-at-start": soc_at_start,
  1852. "soc-max": soc_max,
  1853. "soc-min": soc_min,
  1854. "power-capacity": "2 MW",
  1855. "production-capacity": "2 MW",
  1856. "consumption-capacity": "2 MW",
  1857. "storage-efficiency": 1,
  1858. "charging-efficiency": "100%",
  1859. "discharging-efficiency": "100%",
  1860. }
  1861. def compute_schedule(flex_model):
  1862. scheduler: Scheduler = StorageScheduler(
  1863. power,
  1864. start,
  1865. end,
  1866. resolution,
  1867. flex_model=flex_model,
  1868. flex_context={
  1869. "site-power-capacity": "100 MW",
  1870. "production-price": {"sensor": epex_da.id},
  1871. "consumption-price": {"sensor": epex_da.id},
  1872. },
  1873. )
  1874. return scheduler.compute()
  1875. flex_model["soc-targets"] = {"sensor": soc_targets.id}
  1876. schedule = compute_schedule(flex_model)
  1877. soc = check_constraints(power, schedule, soc_at_start)
  1878. # soc targets are achieved
  1879. assert all(abs(soc[8:].values - expected_soc_schedule) < 1e-5)
  1880. # remove soc-targets and use soc-maxima and soc-minima
  1881. del flex_model["soc-targets"]
  1882. flex_model["soc-minima"] = {"sensor": soc_minima.id}
  1883. flex_model["soc-maxima"] = {"sensor": soc_maxima.id}
  1884. schedule = compute_schedule(flex_model)
  1885. soc = check_constraints(power, schedule, soc_at_start)
  1886. # soc-maxima and soc-minima constraints are respected
  1887. # this yields the same results as with the SOC targets
  1888. # because soc-maxima = soc-minima = soc-targets
  1889. assert all(abs(soc[8:].values - expected_soc_schedule) < 1e-5)
  1890. @pytest.mark.parametrize("unit", [None, "MWh", "kWh"])
  1891. @pytest.mark.parametrize("soc_unit", ["kWh", "MWh"])
  1892. @pytest.mark.parametrize("power_sensor_name", ["power", "power (kW)"])
  1893. def test_battery_storage_different_units(
  1894. add_battery_assets,
  1895. db,
  1896. power_sensor_name,
  1897. soc_unit,
  1898. unit,
  1899. ):
  1900. """
  1901. Test scheduling a 1 MWh battery for 2h with a low -> high price transition with
  1902. different units for the soc-min, soc-max, soc-at-start and power sensor.
  1903. """
  1904. soc_min = ur.Quantity("100 kWh")
  1905. soc_max = ur.Quantity("1 MWh")
  1906. soc_at_start = ur.Quantity("100 kWh")
  1907. if unit is not None:
  1908. soc_min = str(soc_min.to(unit))
  1909. soc_max = str(soc_max.to(unit))
  1910. soc_at_start = str(soc_at_start.to(unit))
  1911. else:
  1912. soc_min = soc_min.to(soc_unit).magnitude
  1913. soc_max = soc_max.to(soc_unit).magnitude
  1914. soc_at_start = soc_at_start.to(soc_unit).magnitude
  1915. epex_da, battery = get_sensors_from_db(
  1916. db,
  1917. add_battery_assets,
  1918. battery_name="Test battery",
  1919. power_sensor_name=power_sensor_name,
  1920. )
  1921. tz = pytz.timezone("Europe/Amsterdam")
  1922. # transition from cheap to expensive (90 -> 100)
  1923. start = tz.localize(datetime(2015, 1, 2, 14, 0, 0))
  1924. end = tz.localize(datetime(2015, 1, 2, 16, 0, 0))
  1925. resolution = timedelta(minutes=15)
  1926. flex_model = {
  1927. "soc-min": soc_min,
  1928. "soc-max": soc_max,
  1929. "soc-at-start": soc_at_start,
  1930. "soc-unit": soc_unit,
  1931. "roundtrip-efficiency": 1,
  1932. "storage-efficiency": 1,
  1933. "power-capacity": "1 MW",
  1934. }
  1935. scheduler: Scheduler = StorageScheduler(
  1936. battery,
  1937. start,
  1938. end,
  1939. resolution,
  1940. flex_model=flex_model,
  1941. flex_context={
  1942. "site-power-capacity": "1 MW",
  1943. },
  1944. )
  1945. schedule = scheduler.compute()
  1946. if power_sensor_name == "power (kW)":
  1947. schedule /= 1000
  1948. # Check if constraints were met
  1949. if isinstance(soc_at_start, str):
  1950. soc_at_start = ur.Quantity(soc_at_start).to("MWh").magnitude
  1951. elif isinstance(soc_at_start, float) or isinstance(soc_at_start, int):
  1952. soc_at_start = soc_at_start * convert_units(1, soc_unit, "MWh")
  1953. check_constraints(battery, schedule, soc_at_start)
  1954. # charge fully in the cheap price period (100 kWh -> 1000kWh)
  1955. assert schedule[:4].sum() * 0.25 == 0.9
  1956. # discharge fully in the expensive price period (1000 kWh -> 100 kWh)
  1957. assert schedule[4:].sum() * 0.25 == -0.9
  1958. @pytest.mark.parametrize(
  1959. "ts_field, ts_specs",
  1960. [
  1961. # The battery only has time to charge up to 950 kWh halfway
  1962. (
  1963. "power-capacity",
  1964. [
  1965. {
  1966. "start": "2015-01-02T15:00+01",
  1967. "end": "2015-01-02T17:00+01",
  1968. "value": "850 kW",
  1969. }
  1970. ],
  1971. ),
  1972. # Same, but the event time is specified with a duration instead of an end time
  1973. (
  1974. "power-capacity",
  1975. [
  1976. {
  1977. "start": "2015-01-02T15:00+01",
  1978. "duration": "PT2H",
  1979. "value": "850 kW",
  1980. }
  1981. ],
  1982. ),
  1983. # Can only charge up to 950 kWh halfway
  1984. (
  1985. "soc-maxima",
  1986. [
  1987. {
  1988. "datetime": "2015-01-02T16:00+01",
  1989. "value": "950 kWh",
  1990. }
  1991. ],
  1992. ),
  1993. # Must end up at a maximum of 200 kWh, for which it is cheapest to charge to 950 and then to discharge to 200
  1994. (
  1995. "soc-maxima",
  1996. [
  1997. {
  1998. "start": "2015-01-02T16:45+01",
  1999. "duration": "PT15M",
  2000. "value": "200 kWh",
  2001. }
  2002. ],
  2003. ),
  2004. ],
  2005. )
  2006. def test_battery_storage_with_time_series_in_flex_model(
  2007. add_battery_assets,
  2008. db,
  2009. ts_field,
  2010. ts_specs,
  2011. ):
  2012. """
  2013. Test scheduling a 1 MWh battery for 2h with a low -> high price transition with
  2014. a time series used for the various flex-model fields.
  2015. """
  2016. soc_min = "100 kWh"
  2017. soc_max = "1 MWh"
  2018. soc_at_start = "100 kWh"
  2019. epex_da, battery = get_sensors_from_db(
  2020. db,
  2021. add_battery_assets,
  2022. battery_name="Test battery",
  2023. power_sensor_name="power",
  2024. )
  2025. tz = pytz.timezone("Europe/Amsterdam")
  2026. # transition from cheap to expensive (90 -> 100)
  2027. start = tz.localize(datetime(2015, 1, 2, 15, 0, 0))
  2028. end = tz.localize(datetime(2015, 1, 2, 17, 0, 0))
  2029. resolution = timedelta(minutes=15)
  2030. flex_model = {
  2031. "soc-min": soc_min,
  2032. "soc-max": soc_max,
  2033. "soc-at-start": soc_at_start,
  2034. "roundtrip-efficiency": 1,
  2035. "storage-efficiency": 1,
  2036. "power-capacity": "1 MW",
  2037. }
  2038. flex_model[ts_field] = ts_specs
  2039. scheduler: Scheduler = StorageScheduler(
  2040. battery,
  2041. start,
  2042. end,
  2043. resolution,
  2044. flex_model=flex_model,
  2045. flex_context={
  2046. "site-power-capacity": "1 MW",
  2047. },
  2048. )
  2049. schedule = scheduler.compute()
  2050. # Check if constraints were met
  2051. soc_at_start = ur.Quantity(soc_at_start).to("MWh").magnitude
  2052. check_constraints(battery, schedule, soc_at_start)
  2053. # charge 850 kWh in the cheap price period (100 kWh -> 950kWh)
  2054. assert schedule[:4].sum() * 0.25 == pytest.approx(0.85)
  2055. # discharge fully or to what's needed in the expensive price period (950 kWh -> 100 or 200 kWh)
  2056. if ts_field == "soc-minima":
  2057. assert schedule[4:].sum() * 0.25 == pytest.approx(-0.75)
  2058. else:
  2059. assert schedule[4:].sum() * 0.25 == pytest.approx(-0.85)
  2060. def test_unavoidable_capacity_breach():
  2061. """Check our ability to limit a capacity breach.
  2062. We want to check two behaviours:
  2063. 1) The breach should be as small as possible (i.e. flattened from the top).
  2064. 2) The breach should not be repeated if that can be avoided (i.e. no taking advantage of created slacks).
  2065. """
  2066. start = pd.Timestamp("2020-01-01T00:00:00+01")
  2067. end = pd.Timestamp("2020-01-02T00:00:00+01")
  2068. resolution = timedelta(hours=1)
  2069. soc_at_start = 0.4
  2070. soc_max = 1
  2071. soc_min = 0
  2072. power_capacity = 0.1
  2073. # This target means we must breach the consumption capacity (from 0.1 to 0.2)
  2074. soc_target = 0.6
  2075. soc_target_datetime = pd.Timestamp("2020-01-01T01:00:00+01")
  2076. device_constraints = [
  2077. initialize_df(StorageScheduler.COLUMNS, start, end, resolution)
  2078. ]
  2079. ems_constraints = initialize_df(StorageScheduler.COLUMNS, start, end, resolution)
  2080. empty_commitment = initialize_df(
  2081. [
  2082. "quantity",
  2083. "downwards deviation price",
  2084. "upwards deviation price",
  2085. "group",
  2086. ],
  2087. start,
  2088. end,
  2089. resolution,
  2090. )
  2091. commitments = []
  2092. commitments.append(empty_commitment.copy())
  2093. device_constraints[0]["max"] = soc_max - soc_at_start
  2094. device_constraints[0]["min"] = soc_min - soc_at_start
  2095. device_constraints[0]["derivative max"] = 1
  2096. device_constraints[0]["derivative min"] = -1
  2097. slope = np.arange(0, len(commitments[0])) / (len(commitments[0]) - 1)
  2098. # Introduce an amazing chance to consume without costs
  2099. slope[10] = -100
  2100. # Day Ahead market commitment
  2101. commitments[0]["quantity"] = 0
  2102. commitments[0]["downwards deviation price"] = 90 + slope
  2103. commitments[0]["upwards deviation price"] = 100 + slope
  2104. # `len(commitments[0])` is the number of time slots. Each slot is part of a unique group.
  2105. commitments[0]["group"] = list(range(len(commitments[0])))
  2106. # Consumption Capacity Breach Commitment (1 group, so penalized once)
  2107. commitments.append(empty_commitment.copy())
  2108. commitments[-1]["quantity"] = power_capacity
  2109. # positive price because breaching in the upwards (consumption) direction is penalized
  2110. commitments[-1]["upwards deviation price"] = 1000
  2111. # todo: also allow None values to model a one-sided commitment
  2112. commitments[-1]["downwards deviation price"] = np.nan
  2113. commitments[-1]["group"] = 1
  2114. def run_scheduler(device_constraints):
  2115. _, _, results, model = device_scheduler(
  2116. device_constraints=device_constraints,
  2117. ems_constraints=ems_constraints,
  2118. commitments=commitments,
  2119. initial_stock=soc_at_start,
  2120. )
  2121. assert results.solver.termination_condition == "optimal"
  2122. schedule = initialize_series(
  2123. data=[model.ems_power[0, j].value for j in model.j],
  2124. start=start,
  2125. end=end,
  2126. resolution=resolution,
  2127. )
  2128. print(schedule)
  2129. return schedule, results
  2130. schedule, results = run_scheduler(device_constraints)
  2131. # Discharge the whole battery, right at the end
  2132. assert np.isclose(sum(schedule), -0.4)
  2133. assert np.isclose(schedule.values[-1], -0.5)
  2134. # Take advantage of the free consumption
  2135. assert np.isclose(schedule.values[10], 0.1)
  2136. # Do nothing otherwise
  2137. assert all(np.isclose(schedule.values[:10], 0))
  2138. assert all(np.isclose(schedule.values[11:-1], 0))
  2139. targets = initialize_series(None, start, end, resolution)
  2140. targets[soc_target_datetime] = soc_target
  2141. # device_constraints[0]["equals"] = targets - soc_at_start
  2142. # Convert SoC datetimes to periods with a start and end.
  2143. soc_values = [{"datetime": soc_target_datetime, "value": soc_target}]
  2144. for soc in soc_values:
  2145. TimedEventSchema().check_time_window(soc)
  2146. device_constraints[0]["equals"] = build_device_soc_values(
  2147. soc_values=soc_values,
  2148. soc_at_start=soc_at_start,
  2149. start_of_schedule=start,
  2150. end_of_schedule=end,
  2151. resolution=resolution,
  2152. )
  2153. schedule, results = run_scheduler(device_constraints)
  2154. # Discharge the whole battery
  2155. assert np.isclose(max(schedule), 0.2) # this is the breach
  2156. # the breach should happen twice:
  2157. # - once for reaching the SoC target, and
  2158. # - once for exploiting the slack that was created for reaching the SoC target
  2159. assert len(schedule[np.isclose(schedule, 0.2)]) == 2
  2160. assert np.isclose(sum(schedule), -0.4)
  2161. # Reach the target (from 0.4 to 0.6)
  2162. assert np.isclose(schedule.values[0], 0.2)
  2163. # Take (too much) advantage of the free consumption
  2164. assert np.isclose(schedule.values[10], 0.2)
  2165. # Do nothing otherwise
  2166. assert all(np.isclose(schedule.values[1:10], 0))
  2167. assert all(np.isclose(schedule.values[11:-1], 0))
  2168. # Consumption Capacity Breach Commitment (n groups, so penalized with every breach)
  2169. commitments.append(empty_commitment.copy())
  2170. commitments[-1]["quantity"] = power_capacity
  2171. # positive price because breaching in the upwards (consumption) direction is penalized
  2172. commitments[-1]["upwards deviation price"] = 1000
  2173. # todo: also allow None values to model a one-sided commitment
  2174. commitments[-1]["downwards deviation price"] = np.nan
  2175. commitments[-1]["group"] = list(range(len(commitments[0])))
  2176. schedule, results = run_scheduler(device_constraints)
  2177. # Discharge the whole battery
  2178. assert np.isclose(max(schedule), 0.2) # this is the breach
  2179. # the breach should now happen only once, for reaching the SoC target
  2180. assert len(schedule[np.isclose(schedule, 0.2)]) == 1
  2181. assert np.isclose(sum(schedule), -0.4)
  2182. # Reach the target (from 0.4 to 0.6)
  2183. assert np.isclose(schedule.values[0], 0.2)
  2184. # Take advantage of the free consumption
  2185. assert np.isclose(schedule.values[10], 0.1)
  2186. # Do nothing otherwise
  2187. assert all(np.isclose(schedule.values[1:10], 0))
  2188. assert all(np.isclose(schedule.values[11:-1], 0))
  2189. def test_multiple_commitments_per_group():
  2190. """Check draining a battery while expanding the number of commitments:
  2191. 1) against increasing prices -> discharge all in the last step
  2192. 2) also with a limited capacity -> discharge as late as possible
  2193. 3) also with peak pricing -> discharge at a constant rate
  2194. """
  2195. start = pd.Timestamp("2020-01-01T00:00:00")
  2196. end = pd.Timestamp("2020-01-02T00:00:00")
  2197. resolution = timedelta(hours=1)
  2198. soc_at_start = 0.4
  2199. soc_max = 1
  2200. soc_min = 0
  2201. device_constraints = [
  2202. initialize_df(StorageScheduler.COLUMNS, start, end, resolution)
  2203. ]
  2204. ems_constraints = initialize_df(StorageScheduler.COLUMNS, start, end, resolution)
  2205. empty_commitment = initialize_df(
  2206. [
  2207. "quantity",
  2208. "downwards deviation price",
  2209. "upwards deviation price",
  2210. "group",
  2211. ],
  2212. start,
  2213. end,
  2214. resolution,
  2215. )
  2216. commitments = []
  2217. commitments.append(empty_commitment.copy())
  2218. device_constraints[0]["max"] = soc_max - soc_at_start
  2219. device_constraints[0]["min"] = soc_min - soc_at_start
  2220. device_constraints[0]["derivative max"] = 1
  2221. device_constraints[0]["derivative min"] = -1
  2222. slope = np.arange(0, len(commitments[0])) / (len(commitments[0]) - 1)
  2223. # Day Ahead market commitment
  2224. commitments[0]["quantity"] = 0
  2225. commitments[0]["downwards deviation price"] = 90 + slope
  2226. commitments[0]["upwards deviation price"] = 100 + slope
  2227. commitments[0]["group"] = list(range(len(commitments[0])))
  2228. def run_scheduler():
  2229. _, _, results, model = device_scheduler(
  2230. device_constraints=device_constraints,
  2231. ems_constraints=ems_constraints,
  2232. commitments=commitments,
  2233. initial_stock=soc_at_start,
  2234. )
  2235. assert results.solver.termination_condition == "optimal"
  2236. schedule = initialize_series(
  2237. data=[model.ems_power[0, j].value for j in model.j],
  2238. start=start,
  2239. end=end,
  2240. resolution=resolution,
  2241. )
  2242. print(schedule)
  2243. costs = value(model.costs)
  2244. commitment_costs = model.commitment_costs
  2245. return schedule, results, costs, commitment_costs
  2246. schedule, results, costs, commitment_costs = run_scheduler()
  2247. # Discharge the whole battery
  2248. assert np.isclose(sum(schedule), -0.4)
  2249. assert np.isclose(schedule.values[-1], -0.4)
  2250. # Check costs
  2251. assert costs == -36.4
  2252. # Production Capacity Breach Commitment
  2253. commitments.append(empty_commitment.copy())
  2254. commitments[-1]["quantity"] = -0.1
  2255. # negative price because breaching in the downwards (production) direction is penalized
  2256. commitments[-1]["downwards deviation price"] = -1000
  2257. # positive price because breaching in the upwards (consumption) direction is penalized
  2258. # todo: also allow None values to model a one-sided commitment
  2259. commitments[-1]["upwards deviation price"] = np.nan
  2260. commitments[-1]["group"] = 1
  2261. schedule, results, costs, commitment_costs = run_scheduler()
  2262. # Discharge the whole battery
  2263. assert np.isclose(sum(schedule), -0.4)
  2264. assert all(np.isclose(schedule.values[-4:], [-0.1, -0.1, -0.1, -0.1]))
  2265. # Check costs
  2266. assert costs == (commitments[0]["downwards deviation price"][-4:] * -0.1).sum()
  2267. # Peak Power Commitment
  2268. commitments.append(empty_commitment.copy())
  2269. commitments[-1]["quantity"] = 0
  2270. # negative price because peaking in the downwards (production) direction is penalized
  2271. commitments[-1]["downwards deviation price"] = -80
  2272. # positive price because breaching in the upwards (consumption) direction is penalized
  2273. commitments[-1]["upwards deviation price"] = 80
  2274. commitments[-1]["group"] = 1
  2275. schedule, results, costs, commitment_costs = run_scheduler()
  2276. # Discharge the whole battery
  2277. assert np.isclose(sum(schedule), -0.4)
  2278. assert all(np.isclose(schedule.values, [-0.4 / len(schedule)] * len(schedule)))
  2279. # Check costs
  2280. cost_of_energy = (
  2281. commitments[0]["downwards deviation price"] * (-0.4 / len(schedule))
  2282. ).sum()
  2283. cost_of_energy_peak = -80 * -0.4 / len(schedule)
  2284. expected_cost = cost_of_energy + cost_of_energy_peak
  2285. assert costs == pytest.approx(expected_cost)
  2286. assert len(commitment_costs) == len(commitments)
  2287. assert sum(commitment_costs.values()) == pytest.approx(expected_cost)
  2288. def test_multiple_devices_simultaneous_scheduler():
  2289. """
  2290. Test the device_scheduler for simultaneously scheduling multiple devices (2):
  2291. - Schedules are created for each device.
  2292. - Costs are calculated accurately.
  2293. Test cases:
  2294. 1. All devices will reach target SOC
  2295. 2. Lower EMS capacity, some unmet demand
  2296. """
  2297. # Test configuration
  2298. start = pd.Timestamp("2020-01-01T00:00:00")
  2299. end = pd.Timestamp("2020-01-02T00:00:00")
  2300. resolution = timedelta(hours=1)
  2301. num_devices = 2
  2302. # Device parameters
  2303. soc_at_start = [0] * num_devices
  2304. soc_max, soc_min = [1] * num_devices, [0] * num_devices
  2305. start_datetime = ["2020-01-01 01:00:00"] * num_devices
  2306. target_datetime = ["2020-01-01 06:00:00", "2020-01-01 03:00:00"]
  2307. target_value = [1] * num_devices
  2308. market_prices = [
  2309. 0.8598,
  2310. 1.4613, # cheap from 01:00 to 02:00
  2311. 2430.3887,
  2312. 3000.1779,
  2313. 18.6619, # cheap from 04:00 to 0:500
  2314. 369.3274,
  2315. 169.8719,
  2316. 174.2279,
  2317. 174.2279,
  2318. 174.2279,
  2319. 175.4258,
  2320. 1.5697,
  2321. 174.2763,
  2322. 174.2279,
  2323. 175.2564,
  2324. 202.6992,
  2325. 218.4413,
  2326. 229.9242,
  2327. 295.1069,
  2328. 240.7174,
  2329. 249.2479,
  2330. 238.2732,
  2331. 229.8395,
  2332. 216.5779,
  2333. ]
  2334. soc_target_penalty = 10000
  2335. def initialize_combined_constraints(
  2336. num_devices: int, max_derivative: float = 1, min_derivative: float = 0
  2337. ):
  2338. device_constraints = []
  2339. for d in range(num_devices):
  2340. constraints = initialize_df(
  2341. StorageScheduler.COLUMNS, start, end, resolution
  2342. )
  2343. start_time = pd.Timestamp(start_datetime[d]) - timedelta(hours=1)
  2344. constraints["max"] = soc_max[d] - soc_at_start[d]
  2345. constraints["min"] = soc_min[d] - soc_at_start[d]
  2346. constraints["derivative max"] = max_derivative
  2347. constraints["derivative min"] = min_derivative
  2348. constraints.loc[
  2349. :start_time, ["max", "min", "derivative max", "derivative min"]
  2350. ] = 0
  2351. device_constraints.append(constraints)
  2352. return device_constraints
  2353. def initialize_combined_commitments(num_devices: int):
  2354. commitments = []
  2355. # Model energy contract for the site
  2356. energy_commitment = initialize_energy_commitment(
  2357. start=start,
  2358. end=end,
  2359. resolution=resolution,
  2360. market_prices=market_prices,
  2361. )
  2362. commitments.append(energy_commitment)
  2363. # Model penalties for demand unmet per device
  2364. for d in range(num_devices):
  2365. device_commitment = initialize_device_commitment(
  2366. start=start,
  2367. end=end,
  2368. resolution=resolution,
  2369. device=d,
  2370. target_datetime=target_datetime[d],
  2371. target_value=target_value[d],
  2372. soc_at_start=soc_at_start[d],
  2373. soc_target_penalty=soc_target_penalty,
  2374. )
  2375. commitments.append(device_commitment)
  2376. return commitments
  2377. ems_constraints = initialize_df(StorageScheduler.COLUMNS, start, end, resolution)
  2378. ems_constraints["derivative max"] = 1
  2379. ems_constraints["derivative min"] = 0
  2380. device_constraints = initialize_combined_constraints(num_devices)
  2381. commitments = initialize_combined_commitments(num_devices)
  2382. # Test case 1: No unmet demand
  2383. initial_stocks = soc_at_start
  2384. _, _, results, model = device_scheduler(
  2385. device_constraints=device_constraints,
  2386. ems_constraints=ems_constraints,
  2387. commitments=commitments,
  2388. initial_stock=initial_stocks,
  2389. )
  2390. assert results.solver.termination_condition == "optimal"
  2391. schedules = [
  2392. initialize_series(
  2393. data=[model.ems_power[d, j].value for j in model.j],
  2394. start=start,
  2395. end=end,
  2396. resolution=resolution,
  2397. )
  2398. for d in range(num_devices)
  2399. ]
  2400. individual_costs = [
  2401. (d, sum(schedule[j] * market_prices[j] for j in range(len(market_prices))))
  2402. for d, schedule in enumerate(schedules)
  2403. ]
  2404. # Expected results with no unmet demand
  2405. expected_schedules = [
  2406. [0] * 4
  2407. + [1]
  2408. + [0] * 19, # the first EV leaves later, and takes the second-cheapest slot
  2409. [0, 1] + [0] * 22, # the second EV leaves earlier, and gets the cheapest slot
  2410. ]
  2411. total_expected_demand = np.array(expected_schedules).sum()
  2412. expected_individual_costs = [(0, 18.66), (1, 1.46)]
  2413. # Assertions
  2414. assert all(
  2415. np.isclose(schedule, expected_schedules[d]).all()
  2416. for d, schedule in enumerate(schedules)
  2417. ), "Schedules mismatch: Device schedules do not match the expected schedules."
  2418. assert all(
  2419. device == d and pytest.approx(cost, 0.01) == expected_individual_costs[d][1]
  2420. for d, (device, cost) in enumerate(individual_costs)
  2421. ), "Individual costs mismatch: Costs for one or more devices are not calculated as expected."
  2422. # Test case 2: With lower EMS capacity and unmet demand
  2423. ems_constraints = initialize_df(StorageScheduler.COLUMNS, start, end, resolution)
  2424. ems_constraints["derivative max"] = 0.25
  2425. ems_constraints["derivative min"] = 0
  2426. device_constraints = initialize_combined_constraints(
  2427. num_devices,
  2428. max_derivative=0.25,
  2429. min_derivative=0, # todo: change does not seem required
  2430. )
  2431. _, _, results, model = device_scheduler(
  2432. device_constraints=device_constraints,
  2433. ems_constraints=ems_constraints,
  2434. commitments=commitments,
  2435. initial_stock=initial_stocks,
  2436. )
  2437. assert results.solver.termination_condition == "optimal"
  2438. schedules = [
  2439. initialize_series(
  2440. data=[model.ems_power[d, j].value for j in model.j],
  2441. start=start,
  2442. end=end,
  2443. resolution=resolution,
  2444. )
  2445. for d in range(num_devices)
  2446. ]
  2447. individual_costs = [
  2448. (d, sum(schedule[j] * market_prices[j] for j in range(len(market_prices))))
  2449. for d, schedule in enumerate(schedules)
  2450. ]
  2451. # Expected results with unfair unmet demand and unfair costs
  2452. expected_schedules = [
  2453. [0, 0.25, 0, 0, 0.25, 0.25, 0.25]
  2454. + [0] * 17, # the first EV leaves later, and takes the four cheapest slots
  2455. [0, 0, 0.25, 0.25]
  2456. + [0]
  2457. * 20, # the second EV leaves earlier, and takes the remaining (expensive) slots
  2458. ]
  2459. total_expected_demand_unmet = (
  2460. total_expected_demand - np.array(expected_schedules).sum()
  2461. )
  2462. assert total_expected_demand_unmet > 0
  2463. expected_individual_costs = [(0, 139.83), (1, 1357.64)]
  2464. # Assertions
  2465. assert all(
  2466. np.isclose(schedule, expected_schedules[d]).all()
  2467. for d, schedule in enumerate(schedules)
  2468. ), "Schedules mismatch: Device schedules do not match the expected schedules."
  2469. assert all(
  2470. device == d and pytest.approx(cost, 0.01) == expected_individual_costs[d][1]
  2471. for d, (device, cost) in enumerate(individual_costs)
  2472. ), "Individual costs mismatch: Costs for one or more devices are not calculated as expected."
  2473. def test_multiple_devices_sequential_scheduler():
  2474. start = pd.Timestamp("2023-01-01T00:00:00")
  2475. end = pd.Timestamp("2023-01-02T00:00:00")
  2476. resolution = timedelta(hours=1)
  2477. soc_at_start = [0] * 2
  2478. soc_max = [1] * 2
  2479. soc_min = [0] * 2
  2480. start_datetime = ["2023-01-01 01:00:00"] * 2
  2481. target_datetime = [
  2482. "2023-01-01 06:00:00",
  2483. "2023-01-01 03:00:00",
  2484. ] # todo: problem with interpreting datetime of soc-target?
  2485. target_value = [1] * 2
  2486. market_prices = [
  2487. 0.8598,
  2488. 1.4613, # cheap from 01:00 to 02:00
  2489. 2430.3887,
  2490. 3000.1779,
  2491. 18.6619, # cheap from 04:00 to 0:500
  2492. 369.3274,
  2493. 169.8719,
  2494. 174.2279,
  2495. 174.2279,
  2496. 174.2279,
  2497. 175.4258,
  2498. 1.5697,
  2499. 174.2763,
  2500. 174.2279,
  2501. 175.2564,
  2502. 202.6992,
  2503. 218.4413,
  2504. 229.9242,
  2505. 295.1069,
  2506. 240.7174,
  2507. 249.2479,
  2508. 238.2732,
  2509. 229.8395,
  2510. 216.5779,
  2511. ]
  2512. soc_target_penalty = 10000
  2513. def initialize_device_constraints(
  2514. num_devices: int,
  2515. soc_at_start: list[float],
  2516. soc_max: list[float],
  2517. soc_min: list[float],
  2518. target_datetime: list[str],
  2519. target_value: list[float],
  2520. start_datetime: list[str],
  2521. ):
  2522. device_constraints = []
  2523. for d in range(num_devices):
  2524. constraints = initialize_df(
  2525. StorageScheduler.COLUMNS, start, end, resolution
  2526. )
  2527. start_time = pd.Timestamp(start_datetime[d]) - timedelta(hours=1)
  2528. constraints["max"] = soc_max[d] - soc_at_start[d]
  2529. constraints["min"] = soc_min[d] - soc_at_start[d]
  2530. constraints["derivative max"] = 1
  2531. constraints["derivative min"] = 0
  2532. # constraints.loc[target_datetime[d], "min"] = (
  2533. # target_value[d] - soc_at_start[d]
  2534. # )
  2535. constraints.loc[
  2536. :start_time, ["max", "min", "derivative max", "derivative min"]
  2537. ] = 0
  2538. device_constraints.append(constraints)
  2539. return device_constraints
  2540. def initialize_ems_constraints():
  2541. ems_constraints = initialize_df(
  2542. StorageScheduler.COLUMNS, start, end, resolution
  2543. )
  2544. ems_constraints["derivative max"] = 1
  2545. ems_constraints["derivative min"] = 0
  2546. return ems_constraints
  2547. def run_sequential_scheduler():
  2548. num_devices = len(soc_at_start)
  2549. device_constraints = initialize_device_constraints(
  2550. num_devices=num_devices,
  2551. soc_at_start=soc_at_start,
  2552. soc_max=soc_max,
  2553. soc_min=soc_min,
  2554. target_datetime=target_datetime,
  2555. target_value=target_value,
  2556. start_datetime=start_datetime,
  2557. )
  2558. # Model energy contract for the site
  2559. energy_commitment = initialize_energy_commitment(
  2560. start=start,
  2561. end=end,
  2562. resolution=resolution,
  2563. market_prices=market_prices,
  2564. )
  2565. ems_constraints = initialize_ems_constraints()
  2566. all_schedules = []
  2567. total_costs = []
  2568. combined_schedule = [0] * len(market_prices)
  2569. unmet_targets = []
  2570. for d in range(num_devices):
  2571. initial_stock = soc_at_start[d]
  2572. # Model penalties for demand unmet per device
  2573. device_commitment = initialize_device_commitment(
  2574. start=start,
  2575. end=end,
  2576. resolution=resolution,
  2577. device=0,
  2578. target_datetime=target_datetime[d],
  2579. target_value=target_value[d],
  2580. soc_at_start=soc_at_start[d],
  2581. soc_target_penalty=soc_target_penalty,
  2582. )
  2583. # Compute the schedule for device d
  2584. _, _, results, model = device_scheduler(
  2585. device_constraints=[device_constraints[d]],
  2586. ems_constraints=ems_constraints,
  2587. commitments=[energy_commitment, device_commitment],
  2588. initial_stock=initial_stock,
  2589. )
  2590. assert results.solver.termination_condition == "optimal"
  2591. schedule = initialize_series(
  2592. data=[model.ems_power[0, j].value for j in model.j],
  2593. start=start,
  2594. end=end,
  2595. resolution=resolution,
  2596. )
  2597. all_schedules.append(schedule)
  2598. for j in range(len(schedule)):
  2599. combined_schedule[j] += schedule[j]
  2600. # Determine new headroom
  2601. ems_constraints["derivative max"] -= schedule
  2602. ems_constraints["derivative min"] -= schedule
  2603. total_cost = sum(
  2604. schedule[j] * market_prices[j] for j in range(len(market_prices))
  2605. )
  2606. total_costs.append(total_cost)
  2607. final_soc = initial_stock + sum(schedule)
  2608. if final_soc < target_value[d]:
  2609. unmet_targets.append((d, final_soc))
  2610. return (
  2611. all_schedules,
  2612. total_costs,
  2613. sum(total_costs),
  2614. combined_schedule,
  2615. unmet_targets,
  2616. )
  2617. schedules, costs, total_cost_all_devices, combined_schedule, unmet_targets = (
  2618. run_sequential_scheduler()
  2619. )
  2620. expected_schedules = [
  2621. [0, 1] + [0] * 22, # the first EV leaves later, but takes the cheapest slot
  2622. [0, 0, 1]
  2623. + [0]
  2624. * 21, # the second EV leaves earlier, and gets the only (expensive) slot left
  2625. ]
  2626. expected_costs = [(0, 1.46), (1, 2430.39)]
  2627. assert all(
  2628. np.isclose(schedules[d], expected_schedules[d]).all()
  2629. for d in range(len(schedules))
  2630. ), "Schedules do not match expected values."
  2631. assert all(
  2632. pytest.approx(costs[d], 0.01) == expected_costs[d][1] for d in range(len(costs))
  2633. ), "Costs do not match expected values."
  2634. assert total_cost_all_devices == sum(
  2635. expected_cost[1] for expected_cost in expected_costs
  2636. ), "Total cost mismatch."