conftest.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. from __future__ import annotations
  2. import pytest
  3. from datetime import datetime, timedelta
  4. from random import random
  5. import pandas as pd
  6. import numpy as np
  7. from flask_sqlalchemy import SQLAlchemy
  8. from statsmodels.api import OLS
  9. from flexmeasures import AssetType, Asset, Sensor
  10. import timely_beliefs as tb
  11. from sqlalchemy import select
  12. from flexmeasures.data.models.reporting import Reporter
  13. from flexmeasures.data.schemas.reporting import ReporterParametersSchema
  14. from flexmeasures.data.models.annotations import Annotation
  15. from flexmeasures.data.models.data_sources import DataSource
  16. from flexmeasures.data.models.time_series import TimedBelief
  17. from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
  18. from flexmeasures.data.models.forecasting import model_map
  19. from flexmeasures.data.models.forecasting.model_spec_factory import (
  20. create_initial_model_specs,
  21. )
  22. from flexmeasures.utils.time_utils import as_server_time
  23. from marshmallow import fields
  24. from marshmallow import Schema
  25. @pytest.fixture(scope="module")
  26. def setup_test_data(
  27. db,
  28. app,
  29. add_market_prices,
  30. setup_assets,
  31. setup_generic_asset_types,
  32. ):
  33. """
  34. Adding a few forecasting jobs (based on data made in flexmeasures.conftest).
  35. """
  36. print("Setting up data for data tests on %s" % db.engine)
  37. add_test_weather_sensor_and_forecasts(db, setup_generic_asset_types)
  38. print("Done setting up data for data tests")
  39. return setup_assets
  40. @pytest.fixture(scope="function")
  41. def setup_fresh_test_data(
  42. fresh_db,
  43. setup_markets_fresh_db,
  44. setup_accounts_fresh_db,
  45. setup_assets_fresh_db,
  46. setup_generic_asset_types_fresh_db,
  47. app,
  48. ) -> dict[str, GenericAsset]:
  49. add_test_weather_sensor_and_forecasts(fresh_db, setup_generic_asset_types_fresh_db)
  50. return setup_assets_fresh_db
  51. def add_test_weather_sensor_and_forecasts(db: SQLAlchemy, setup_generic_asset_types):
  52. """one day of test data (one complete sine curve) for two sensors"""
  53. data_source = db.session.execute(
  54. select(DataSource).filter_by(name="Seita", type="demo script")
  55. ).scalar_one_or_none()
  56. weather_station = GenericAsset(
  57. name="Test weather station farther away",
  58. generic_asset_type=setup_generic_asset_types["weather_station"],
  59. latitude=100,
  60. longitude=100,
  61. )
  62. for sensor_name, unit in (("irradiance", "kW/m²"), ("wind speed", "m/s")):
  63. sensor = Sensor(name=sensor_name, generic_asset=weather_station, unit=unit)
  64. db.session.add(sensor)
  65. time_slots = pd.date_range(
  66. datetime(2015, 1, 1), datetime(2015, 1, 2, 23, 45), freq="15min"
  67. )
  68. values = [random() * (1 + np.sin(x / 15)) for x in range(len(time_slots))]
  69. if sensor_name == "temperature":
  70. values = [value * 17 for value in values]
  71. if sensor_name == "wind speed":
  72. values = [value * 45 for value in values]
  73. if sensor_name == "irradiance":
  74. values = [value * 600 for value in values]
  75. for dt, val in zip(time_slots, values):
  76. db.session.add(
  77. TimedBelief(
  78. sensor=sensor,
  79. event_start=as_server_time(dt),
  80. event_value=val,
  81. belief_horizon=timedelta(hours=6),
  82. source=data_source,
  83. )
  84. )
  85. @pytest.fixture(scope="module", autouse=True)
  86. def add_failing_test_model(db):
  87. """Add a test model specs to the lookup which should fail due to missing data.
  88. It falls back to linear OLS (which falls back to naive)."""
  89. def test_specs(**args):
  90. """Customize initial specs with OLS and too early training start."""
  91. model_specs = create_initial_model_specs(**args)
  92. model_specs.set_model(OLS)
  93. model_specs.start_of_training = model_specs.start_of_training - timedelta(
  94. days=365
  95. )
  96. model_identifier = "failing-test model v1"
  97. return model_specs, model_identifier, "linear-OLS"
  98. model_map["failing-test"] = test_specs
  99. @pytest.fixture(scope="module")
  100. def add_nearby_weather_sensors(db, add_weather_sensors) -> dict[str, Sensor]:
  101. temp_sensor_location = add_weather_sensors["temperature"].generic_asset.location
  102. weather_station_type = db.session.execute(
  103. select(GenericAssetType).filter_by(name="weather station")
  104. ).scalar_one_or_none()
  105. farther_weather_station = GenericAsset(
  106. name="Test weather station farther away",
  107. generic_asset_type=weather_station_type,
  108. latitude=temp_sensor_location[0],
  109. longitude=temp_sensor_location[1] + 0.1,
  110. )
  111. db.session.add(farther_weather_station)
  112. farther_temp_sensor = Sensor(
  113. name="temperature",
  114. generic_asset=farther_weather_station,
  115. event_resolution=timedelta(minutes=5),
  116. unit="°C",
  117. )
  118. db.session.add(farther_temp_sensor)
  119. even_farther_weather_station = GenericAsset(
  120. name="Test weather station even farther away",
  121. generic_asset_type=weather_station_type,
  122. latitude=temp_sensor_location[0],
  123. longitude=temp_sensor_location[1] + 0.2,
  124. )
  125. db.session.add(even_farther_weather_station)
  126. even_farther_temp_sensor = Sensor(
  127. name="temperature",
  128. generic_asset=even_farther_weather_station,
  129. event_resolution=timedelta(minutes=5),
  130. unit="°C",
  131. )
  132. db.session.add(even_farther_temp_sensor)
  133. add_weather_sensors["farther_temperature"] = farther_temp_sensor
  134. add_weather_sensors["even_farther_temperature"] = even_farther_temp_sensor
  135. return add_weather_sensors
  136. @pytest.fixture(scope="module")
  137. def setup_annotations(
  138. db,
  139. battery_soc_sensor,
  140. setup_sources,
  141. app,
  142. ):
  143. """Set up an annotation for an account, an asset and a sensor."""
  144. sensor = battery_soc_sensor
  145. asset = sensor.generic_asset
  146. account = asset.owner
  147. source = setup_sources["Seita"]
  148. annotation = Annotation(
  149. content="Dutch new year",
  150. start=pd.Timestamp("2020-01-01 00:00+01"),
  151. end=pd.Timestamp("2020-01-02 00:00+01"),
  152. source=source,
  153. type="holiday",
  154. )
  155. account.annotations.append(annotation)
  156. asset.annotations.append(annotation)
  157. sensor.annotations.append(annotation)
  158. db.session.flush()
  159. return dict(
  160. annotation=annotation,
  161. account=account,
  162. asset=asset,
  163. sensor=sensor,
  164. )
  165. @pytest.fixture(scope="module")
  166. def test_reporter(app, db, add_nearby_weather_sensors):
  167. class TestReporterConfigSchema(Schema):
  168. a = fields.Str()
  169. class TestReporterParametersSchema(ReporterParametersSchema):
  170. b = fields.Str(required=False)
  171. class TestReporter(Reporter):
  172. _config_schema = TestReporterConfigSchema()
  173. _parameters_schema = TestReporterParametersSchema()
  174. def _compute_report(self, **kwargs) -> list:
  175. start = kwargs.get("start")
  176. end = kwargs.get("end")
  177. sensor = kwargs["output"][0]["sensor"]
  178. resolution = sensor.event_resolution
  179. index = pd.date_range(start=start, end=end, freq=resolution)
  180. r = pd.DataFrame()
  181. r["event_start"] = index
  182. r["belief_time"] = index
  183. r["source"] = self.data_source
  184. r["cumulative_probability"] = 0.5
  185. r["event_value"] = 0
  186. bdf = tb.BeliefsDataFrame(r, sensor=sensor)
  187. return [{"data": bdf, "sensor": sensor}]
  188. app.data_generators["reporter"].update({"TestReporter": TestReporter})
  189. config = dict(a="b")
  190. ds = TestReporter(config=config).data_source
  191. assert ds.name == app.config.get("FLEXMEASURES_DEFAULT_DATASOURCE")
  192. db.session.add(ds)
  193. db.session.commit()
  194. return ds
  195. @pytest.fixture(scope="function")
  196. def smart_building_types(app, fresh_db, setup_generic_asset_types_fresh_db):
  197. site = AssetType(name="site")
  198. building = AssetType(name="building")
  199. ev = AssetType(name="ev")
  200. heat_buffer = AssetType(name="heat buffer")
  201. fresh_db.session.add_all([site, building, ev, heat_buffer])
  202. fresh_db.session.flush()
  203. return (
  204. site,
  205. setup_generic_asset_types_fresh_db["solar"],
  206. building,
  207. setup_generic_asset_types_fresh_db["battery"],
  208. ev,
  209. heat_buffer,
  210. )
  211. @pytest.fixture(scope="function")
  212. def smart_building(app, fresh_db, smart_building_types):
  213. """
  214. Topology of the sytstem:
  215. +---------+
  216. | |
  217. +------------------ Site +--------------+------------------+
  218. | | | | |
  219. | +-+----+--+ | |
  220. | | | | |
  221. | | | | |
  222. | +----+ +--+ | |
  223. | | | | |
  224. +----+----+ +------+-----+ +--+---+ +------+------+ +------+------+
  225. | | | | | | | | | |
  226. | Solar | | Building | | EV | | Battery | | Heat Buffer |
  227. | | | | | | | | | |
  228. +---------+ +------------+ +------+ +-------------+ +-------------+
  229. Diagram created with: https://textik.com/#924f8a2112551f92
  230. """
  231. site, solar, building, battery, ev, heat_buffer = smart_building_types
  232. coordinates = {"latitude": 0, "longitude": 0}
  233. test_site = Asset(name="Test Site", generic_asset_type_id=site.id, **coordinates)
  234. fresh_db.session.add(test_site)
  235. fresh_db.session.flush()
  236. test_building = Asset(
  237. name="Test Building",
  238. generic_asset_type_id=building.id,
  239. parent_asset_id=test_site.id,
  240. **coordinates,
  241. )
  242. test_solar = Asset(
  243. name="Test Solar",
  244. generic_asset_type_id=solar.id,
  245. parent_asset_id=test_site.id,
  246. **coordinates,
  247. )
  248. test_battery = Asset(
  249. name="Test Battery",
  250. generic_asset_type_id=battery.id,
  251. parent_asset_id=test_site.id,
  252. **coordinates,
  253. )
  254. test_ev = Asset(
  255. name="Test EV",
  256. generic_asset_type_id=ev.id,
  257. parent_asset_id=test_site.id,
  258. **coordinates,
  259. )
  260. test_battery_1h = Asset(
  261. name="Test Battery 1h",
  262. generic_asset_type_id=battery.id,
  263. parent_asset_id=test_site.id,
  264. **coordinates,
  265. )
  266. test_heat_buffer = Asset(
  267. name="Test Heat Buffer",
  268. generic_asset_type_id=heat_buffer.id,
  269. parent_asset_id=test_site.id,
  270. **coordinates,
  271. )
  272. assets = (
  273. test_site,
  274. test_building,
  275. test_solar,
  276. test_battery,
  277. test_ev,
  278. test_battery_1h,
  279. test_heat_buffer,
  280. )
  281. fresh_db.session.add_all(assets)
  282. fresh_db.session.flush()
  283. power_sensors = []
  284. soc_sensors = []
  285. for asset in assets:
  286. # Add power sensor
  287. sensor = Sensor(
  288. name="power",
  289. unit="MW",
  290. event_resolution=(
  291. timedelta(hours=1)
  292. if asset.name == "Test Battery 1h"
  293. else timedelta(minutes=15)
  294. ),
  295. generic_asset=asset,
  296. timezone="Europe/Amsterdam",
  297. )
  298. power_sensors.append(sensor)
  299. # Add SOC sensors
  300. sensor = Sensor(
  301. "state of charge",
  302. unit="MWh",
  303. event_resolution=timedelta(hours=0),
  304. generic_asset=asset,
  305. timezone="Europe/Amsterdam",
  306. )
  307. soc_sensors.append(sensor)
  308. fresh_db.session.add_all(power_sensors)
  309. fresh_db.session.add_all(soc_sensors)
  310. fresh_db.session.flush()
  311. asset_names = [asset.name for asset in assets]
  312. return (
  313. dict(zip(asset_names, assets)),
  314. dict(zip(asset_names, power_sensors)),
  315. dict(zip(asset_names, soc_sensors)),
  316. )
  317. @pytest.fixture(scope="function")
  318. def flex_description_sequential(
  319. smart_building, setup_markets_fresh_db, add_market_prices_fresh_db
  320. ):
  321. """Set up a flex-context and a partially deserialized flex-model.
  322. Specifically, the main flex model is deserialized, while the sensors' individual flex models are still serialized.
  323. """
  324. assets, sensors, soc_sensors = smart_building
  325. flex_model = [
  326. {
  327. "sensor": sensors["Test EV"],
  328. "sensor_flex_model": {
  329. "consumption-capacity": "5kW",
  330. "production-capacity": "0kW",
  331. "power-capacity": "5kW",
  332. "soc-at-start": 0.00, # 0 kWh
  333. "soc-unit": "MWh",
  334. "soc-min": 0.0,
  335. "soc-max": 0.05, # 50 kWh
  336. "soc-targets": [
  337. {
  338. "start": "2015-01-03T00:00:00+01:00",
  339. "end": "2015-01-03T05:00:00+01:00",
  340. "value": 0.0,
  341. },
  342. {
  343. "datetime": "2015-01-03T07:45:00+01:00",
  344. "value": 0.0125,
  345. }, # 12.5 kWh
  346. {"datetime": "2015-01-03T17:45:00+01:00", "value": 0.025}, # 25 kWh
  347. {
  348. "datetime": "2015-01-03T23:45:00+01:00",
  349. "value": 0.0375,
  350. }, # 37.5 kWh
  351. ],
  352. },
  353. },
  354. {
  355. "sensor": sensors["Test Battery"],
  356. "sensor_flex_model": {
  357. "consumption-capacity": "0kW",
  358. "production-capacity": "5kW",
  359. "power-capacity": "5kW",
  360. "soc-at-start": 0.1, # 100 kWh
  361. "soc-unit": "MWh",
  362. "soc-min": 0.0,
  363. "soc-max": 0.1, # 100 kWh
  364. "soc-targets": [
  365. {
  366. "datetime": "2015-01-03T03:00:00+01:00",
  367. "value": 0.094,
  368. } # 6 kWh discharge
  369. ],
  370. },
  371. },
  372. ]
  373. flex_context = {
  374. "consumption-price-sensor": setup_markets_fresh_db["epex_da"].id,
  375. "production-price-sensor": setup_markets_fresh_db["epex_da"].id,
  376. "inflexible-device-sensors": [
  377. sensors["Test Solar"].id,
  378. sensors["Test Building"].id,
  379. ],
  380. "site-production-capacity": "2kW",
  381. "site-consumption-capacity": "5kW",
  382. }
  383. return dict(flex_model=flex_model, flex_context=flex_context)