test_storage.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. from datetime import datetime, timedelta
  2. import pytz
  3. import numpy as np
  4. import pandas as pd
  5. from flexmeasures.data.models.planning import Scheduler
  6. from flexmeasures.data.models.planning.storage import StorageScheduler
  7. from flexmeasures.data.models.planning.utils import initialize_index
  8. from flexmeasures.data.models.planning.tests.utils import (
  9. check_constraints,
  10. get_sensors_from_db,
  11. series_to_ts_specs,
  12. )
  13. def test_battery_solver_multi_commitment(add_battery_assets, db):
  14. _, battery = get_sensors_from_db(
  15. db, add_battery_assets, battery_name="Test battery"
  16. )
  17. tz = pytz.timezone("Europe/Amsterdam")
  18. start = tz.localize(datetime(2015, 1, 1))
  19. end = tz.localize(datetime(2015, 1, 2))
  20. resolution = timedelta(minutes=15)
  21. soc_at_start = 0.4
  22. index = initialize_index(start=start, end=end, resolution=resolution)
  23. production_prices = pd.Series(90, index=index)
  24. consumption_prices = pd.Series(100, index=index)
  25. scheduler: Scheduler = StorageScheduler(
  26. battery,
  27. start,
  28. end,
  29. resolution,
  30. flex_model={
  31. "soc-at-start": f"{soc_at_start} MWh",
  32. "soc-min": "0 MWh",
  33. "soc-max": "1 MWh",
  34. "power-capacity": "1 MVA",
  35. "soc-minima": [
  36. {
  37. "datetime": "2015-01-02T00:00:00+01:00",
  38. "value": "1 MWh",
  39. }
  40. ],
  41. "prefer-charging-sooner": False,
  42. },
  43. flex_context={
  44. "consumption-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"),
  45. "production-price": series_to_ts_specs(production_prices, unit="EUR/MWh"),
  46. "site-power-capacity": "2 MW", # should be big enough to avoid any infeasibilities
  47. "site-consumption-capacity": "1 kW", # we'll need to breach this to reach the target
  48. "site-consumption-breach-price": "1000 EUR/kW",
  49. "site-production-breach-price": "1000 EUR/kW",
  50. "site-peak-consumption": "20 kW",
  51. "site-peak-production": "20 kW",
  52. "site-peak-consumption-price": "260 EUR/MW",
  53. # The following is a constant price, but this checks currency conversion in case a later price field is
  54. # set to a time series specs (i.e. a list of dicts, where each dict represents a time slot)
  55. "site-peak-production-price": series_to_ts_specs(
  56. pd.Series(260, production_prices.index), unit="EUR/MW"
  57. ),
  58. "soc-minima-breach-price": "6000 EUR/kWh", # high breach price (to mimic a hard constraint)
  59. },
  60. return_multiple=True,
  61. )
  62. results = scheduler.compute()
  63. schedule = results[0]["data"]
  64. costs = results[1]["data"]
  65. costs_unit = results[1]["unit"]
  66. assert costs_unit == "EUR"
  67. # Check if constraints were met
  68. check_constraints(battery, schedule, soc_at_start)
  69. # Check for constant charging profile (minimizing the consumption breach)
  70. np.testing.assert_allclose(schedule, (1 - 0.4) / 24)
  71. # Check costs are correct
  72. # 60 EUR for 600 kWh consumption priced at 100 EUR/MWh
  73. np.testing.assert_almost_equal(costs["energy"], 100 * (1 - 0.4))
  74. # 24000 EUR for any 24 kW consumption breach priced at 1000 EUR/kW
  75. np.testing.assert_almost_equal(costs["any consumption breach"], 1000 * (25 - 1))
  76. # 24000 EUR for each 24 kW consumption breach per hour priced at 1000 EUR/kWh
  77. np.testing.assert_almost_equal(
  78. costs["all consumption breaches"], 1000 * (25 - 1) * 96 / 4
  79. )
  80. # No production breaches
  81. np.testing.assert_almost_equal(costs["any production breach"], 0)
  82. np.testing.assert_almost_equal(costs["all production breaches"], 0 * 96)
  83. # 1.3 EUR for the 5 kW extra consumption peak priced at 260 EUR/MW
  84. np.testing.assert_almost_equal(costs["consumption peak"], 260 / 1000 * (25 - 20))
  85. # No production peak
  86. np.testing.assert_almost_equal(costs["production peak"], 0)
  87. def test_battery_relaxation(add_battery_assets, db):
  88. """Check that resolving SoC breaches is more important than resolving device power breaches.
  89. The battery is still charging with 25 kW between noon and 4 PM, when the consumption capacity is supposed to be 0.
  90. It is still charging because resolving the still unmatched SoC minima takes precedence (via breach prices).
  91. """
  92. _, battery = get_sensors_from_db(
  93. db, add_battery_assets, battery_name="Test battery"
  94. )
  95. tz = pytz.timezone("Europe/Amsterdam")
  96. start = tz.localize(datetime(2015, 1, 1))
  97. end = tz.localize(datetime(2015, 1, 2))
  98. resolution = timedelta(minutes=15)
  99. soc_at_start = 0.4
  100. index = initialize_index(start=start, end=end, resolution=resolution)
  101. consumption_prices = pd.Series(100, index=index)
  102. # Introduce arbitrage opportunity
  103. consumption_prices["2015-01-01T16:00:00+01:00":"2015-01-01T17:00:00+01:00"] = (
  104. 0 # cheap energy
  105. )
  106. consumption_prices["2015-01-01T17:00:00+01:00":"2015-01-01T18:00:00+01:00"] = (
  107. 1000 # expensive energy
  108. )
  109. production_prices = consumption_prices - 10
  110. device_power_breach_price = 100
  111. # Set up consumption/production capacity as a time series
  112. # i.e. it takes 16 hours to go from 0.4 to 0.8 MWh
  113. consumption_capacity_in_mw = 0.025
  114. consumption_capacity = pd.Series(consumption_capacity_in_mw, index=index)
  115. consumption_capacity["2015-01-01T12:00:00+01:00":"2015-01-01T18:00:00+01:00"] = (
  116. 0 # no charging
  117. )
  118. production_capacity = consumption_capacity
  119. scheduler: Scheduler = StorageScheduler(
  120. battery,
  121. start,
  122. end,
  123. resolution,
  124. flex_model={
  125. "soc-at-start": f"{soc_at_start} MWh",
  126. "soc-min": "0 MWh",
  127. "soc-max": "1 MWh",
  128. "power-capacity": f"{consumption_capacity_in_mw} MVA",
  129. "consumption-capacity": series_to_ts_specs(consumption_capacity, unit="MW"),
  130. "production-capacity": series_to_ts_specs(production_capacity, unit="MW"),
  131. "soc-minima": [
  132. {
  133. "start": "2015-01-01T12:00:00+01:00",
  134. "duration": "PT6H",
  135. "value": "0.8 MWh",
  136. }
  137. ],
  138. "prefer-charging-sooner": False,
  139. },
  140. flex_context={
  141. "consumption-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"),
  142. "production-price": series_to_ts_specs(production_prices, unit="EUR/MWh"),
  143. "site-power-capacity": "2 MW", # should be big enough to avoid any infeasibilities
  144. # "site-consumption-capacity": "1 kW", # we'll need to breach this to reach the target
  145. "site-consumption-breach-price": "1000 EUR/kW",
  146. "site-production-breach-price": "1000 EUR/kW",
  147. "site-peak-consumption": "20 kW",
  148. "site-peak-production": "20 kW",
  149. "site-peak-consumption-price": "260 EUR/MW",
  150. # The following is a constant price, but this checks currency conversion in case a later price field is
  151. # set to a time series specs (i.e. a list of dicts, where each dict represents a time slot)
  152. "site-peak-production-price": series_to_ts_specs(
  153. pd.Series(260, production_prices.index), unit="EUR/MW"
  154. ),
  155. "soc-minima-breach-price": "6000 EUR/kWh", # high breach price (to mimic a hard constraint)
  156. "consumption-breach-price": f"{device_power_breach_price} EUR/kW", # lower breach price (thus prioritizing minimizing soc breaches)
  157. "production-breach-price": f"{device_power_breach_price} EUR/kW", # lower breach price (thus prioritizing minimizing soc breaches)
  158. },
  159. return_multiple=True,
  160. )
  161. results = scheduler.compute()
  162. schedule = results[0]["data"]
  163. costs = results[1]["data"]
  164. costs_unit = results[1]["unit"]
  165. assert costs_unit == "EUR"
  166. # Check if constraints were met
  167. check_constraints(battery, schedule, soc_at_start)
  168. # Check for constant charging profile until 4 PM (thus breaching the consumption capacity after noon)
  169. np.testing.assert_allclose(
  170. schedule[:"2015-01-01T15:45:00+01:00"], consumption_capacity_in_mw
  171. )
  172. # Check for standing idle from 4 PM to 6 PM
  173. np.testing.assert_allclose(
  174. schedule["2015-01-01T16:00:00+01:00":"2015-01-01T17:45:00+01:00"], 0
  175. )
  176. # Check costs are correct
  177. np.testing.assert_almost_equal(
  178. costs["any consumption breach device 0"],
  179. device_power_breach_price * consumption_capacity_in_mw * 1000,
  180. ) # 100 EUR/kW * 0.025 MW * 1000 kW/MW
  181. np.testing.assert_almost_equal(
  182. costs["all consumption breaches device 0"],
  183. device_power_breach_price * consumption_capacity_in_mw * 1000 * 4,
  184. ) # 100 EUR/(kW*h) * 0.025 MW * 1000 kW/MW * 4 hours