data_add.py 75 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313
  1. """
  2. CLI commands for populating the database
  3. """
  4. from __future__ import annotations
  5. from datetime import datetime, timedelta
  6. from typing import Type, Dict, Any
  7. import isodate
  8. import json
  9. import yaml
  10. from pathlib import Path
  11. from io import TextIOBase
  12. from string import Template
  13. from marshmallow import validate
  14. import pandas as pd
  15. import pytz
  16. from flask import current_app as app
  17. from flask.cli import with_appcontext
  18. import click
  19. import getpass
  20. from sqlalchemy.exc import IntegrityError
  21. from sqlalchemy import func, select
  22. from timely_beliefs.sensors.func_store.knowledge_horizons import x_days_ago_at_y_oclock
  23. import timely_beliefs as tb
  24. import timely_beliefs.utils as tb_utils
  25. from workalendar.registry import registry as workalendar_registry
  26. from flexmeasures.cli.utils import (
  27. DeprecatedDefaultGroup,
  28. MsgStyle,
  29. DeprecatedOption,
  30. DeprecatedOptionsCommand,
  31. )
  32. from flexmeasures.data import db
  33. from flexmeasures.data.scripts.data_gen import (
  34. add_transmission_zone_asset,
  35. populate_initial_structure,
  36. add_default_asset_types,
  37. )
  38. from flexmeasures.data.services.data_sources import get_or_create_source
  39. from flexmeasures.data.services.forecasting import create_forecasting_jobs
  40. from flexmeasures.data.services.scheduling import make_schedule, create_scheduling_job
  41. from flexmeasures.data.services.users import create_user
  42. from flexmeasures.data.models.user import Account, AccountRole, RolesAccounts
  43. from flexmeasures.data.models.time_series import (
  44. Sensor,
  45. TimedBelief,
  46. )
  47. from flexmeasures.data.models.data_sources import DataSource
  48. from flexmeasures.data.models.validation_utils import (
  49. check_required_attributes,
  50. MissingAttributeException,
  51. )
  52. from flexmeasures.data.models.annotations import Annotation, get_or_create_annotation
  53. from flexmeasures.data.schemas import (
  54. AccountIdField,
  55. AwareDateTimeField,
  56. DurationField,
  57. LatitudeField,
  58. LongitudeField,
  59. SensorIdField,
  60. TimeIntervalField,
  61. VariableQuantityField,
  62. )
  63. from flexmeasures.data.schemas.sources import DataSourceIdField
  64. from flexmeasures.data.schemas.times import TimeIntervalSchema
  65. from flexmeasures.data.schemas.scheduling.storage import EfficiencyField
  66. from flexmeasures.data.schemas.sensors import SensorSchema
  67. from flexmeasures.data.schemas.io import Output
  68. from flexmeasures.data.schemas.units import QuantityField
  69. from flexmeasures.data.schemas.generic_assets import (
  70. GenericAssetSchema,
  71. GenericAssetTypeSchema,
  72. )
  73. from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
  74. from flexmeasures.data.models.user import User
  75. from flexmeasures.data.services.data_sources import (
  76. get_source_or_none,
  77. )
  78. from flexmeasures.data.services.utils import get_or_create_model
  79. from flexmeasures.utils import flexmeasures_inflection
  80. from flexmeasures.utils.time_utils import server_now, apply_offset_chain
  81. from flexmeasures.utils.unit_utils import convert_units, ur
  82. from flexmeasures.cli.utils import validate_color_cli, validate_url_cli
  83. from flexmeasures.data.utils import save_to_db
  84. from flexmeasures.data.services.utils import get_asset_or_sensor_ref
  85. from flexmeasures.data.models.reporting import Reporter
  86. from flexmeasures.data.models.reporting.profit import ProfitOrLossReporter
  87. from timely_beliefs import BeliefsDataFrame
  88. @click.group("add")
  89. def fm_add_data():
  90. """FlexMeasures: Add data."""
  91. @fm_add_data.command("sources")
  92. @click.option(
  93. "--kind",
  94. default=["reporter"],
  95. type=click.Choice(["reporter", "scheduler", "forecaster"]),
  96. multiple=True,
  97. help="What kind of data generators to consider in the creation of the basic DataSources. Defaults to `reporter`.",
  98. )
  99. @with_appcontext
  100. def add_sources(kind: list[str]):
  101. """Create data sources for the data generators found registered in the
  102. application and the plugins. Currently, this command only registers the
  103. sources for the Reporters.
  104. """
  105. for k in kind:
  106. # todo: add other data-generators when adapted (and remove this check when all listed under our click.Choice are represented)
  107. if k not in ("reporter",):
  108. click.secho(f"Oh no, we don't support kind '{k}' yet.", **MsgStyle.WARN)
  109. continue
  110. click.echo(f"Adding `DataSources` for the {k} data generators.")
  111. for name, data_generator in app.data_generators[k].items():
  112. ds_info = data_generator.get_data_source_info()
  113. # add empty data_generator configuration
  114. ds_info["attributes"] = {"data_generator": {"config": {}, "parameters": {}}}
  115. source = get_or_create_source(**ds_info)
  116. click.secho(
  117. f"Done. DataSource for data generator `{name}` is `{source}`.",
  118. **MsgStyle.SUCCESS,
  119. )
  120. db.session.commit()
  121. @fm_add_data.command("account-role")
  122. @with_appcontext
  123. @click.option("--name", required=True)
  124. @click.option("--description")
  125. def new_account_role(name: str, description: str):
  126. """
  127. Create an account role.
  128. """
  129. role = db.session.execute(
  130. select(AccountRole).filter_by(name=name)
  131. ).scalar_one_or_none()
  132. if role is not None:
  133. click.secho(f"Account role '{name}' already exists.", **MsgStyle.ERROR)
  134. raise click.Abort()
  135. role = AccountRole(
  136. name=name,
  137. description=description,
  138. )
  139. db.session.add(role)
  140. db.session.commit()
  141. click.secho(
  142. f"Account role '{name}' (ID: {role.id}) successfully created.",
  143. **MsgStyle.SUCCESS,
  144. )
  145. @fm_add_data.command("account")
  146. @with_appcontext
  147. @click.option("--name", required=True)
  148. @click.option("--roles", help="e.g. anonymous,Prosumer,CPO")
  149. @click.option(
  150. "--primary-color",
  151. callback=validate_color_cli,
  152. help="Primary color to use in UI, in hex format. Defaults to FlexMeasures' primary color (#1a3443)",
  153. )
  154. @click.option(
  155. "--secondary-color",
  156. callback=validate_color_cli,
  157. help="Secondary color to use in UI, in hex format. Defaults to FlexMeasures' secondary color (#f1a122)",
  158. )
  159. @click.option(
  160. "--logo-url",
  161. callback=validate_url_cli,
  162. help="Logo URL to use in UI. Defaults to FlexMeasures' logo URL",
  163. )
  164. @click.option(
  165. "--consultancy",
  166. "consultancy_account",
  167. type=AccountIdField(required=False),
  168. help="ID of the consultancy account, whose consultants will have read access to this account",
  169. )
  170. def new_account(
  171. name: str,
  172. roles: str,
  173. consultancy_account: Account | None,
  174. primary_color: str | None,
  175. secondary_color: str | None,
  176. logo_url: str | None,
  177. ):
  178. """
  179. Create an account for a tenant in the FlexMeasures platform.
  180. """
  181. account = db.session.execute(
  182. select(Account).filter_by(name=name)
  183. ).scalar_one_or_none()
  184. if account is not None:
  185. click.secho(f"Account '{name}' already exists.", **MsgStyle.ERROR)
  186. raise click.Abort()
  187. # make sure both colors or none are given
  188. if (primary_color and not secondary_color) or (
  189. not primary_color and secondary_color
  190. ):
  191. click.secho(
  192. "Please provide both primary_color and secondary_color, or leave both fields blank.",
  193. **MsgStyle.ERROR,
  194. )
  195. raise click.Abort()
  196. # Add '#' if color is given and doesn't already start with it
  197. primary_color = (
  198. f"#{primary_color}"
  199. if primary_color and not primary_color.startswith("#")
  200. else primary_color
  201. )
  202. secondary_color = (
  203. f"#{secondary_color}"
  204. if secondary_color and not secondary_color.startswith("#")
  205. else secondary_color
  206. )
  207. account = Account(
  208. name=name,
  209. consultancy_account=consultancy_account,
  210. primary_color=primary_color,
  211. secondary_color=secondary_color,
  212. logo_url=logo_url,
  213. )
  214. db.session.add(account)
  215. if roles:
  216. for role_name in roles.split(","):
  217. role = db.session.execute(
  218. select(AccountRole).filter_by(name=role_name)
  219. ).scalar_one_or_none()
  220. if role is None:
  221. click.secho(f"Adding account role {role_name} ...", **MsgStyle.ERROR)
  222. role = AccountRole(name=role_name)
  223. db.session.add(role)
  224. db.session.flush()
  225. db.session.add(RolesAccounts(role_id=role.id, account_id=account.id))
  226. db.session.commit()
  227. click.secho(
  228. f"Account '{name}' (ID: {account.id}) successfully created.",
  229. **MsgStyle.SUCCESS,
  230. )
  231. @fm_add_data.command("user", cls=DeprecatedOptionsCommand)
  232. @with_appcontext
  233. @click.option("--username", required=True)
  234. @click.option("--email", required=True)
  235. @click.option(
  236. "--account",
  237. "--account-id",
  238. "account_id",
  239. type=int,
  240. required=True,
  241. cls=DeprecatedOption,
  242. deprecated=["--account-id"],
  243. preferred="--account",
  244. help="Add user to this account. Follow up with the account's ID.",
  245. )
  246. @click.option("--roles", help="e.g. anonymous,Prosumer,CPO")
  247. @click.option(
  248. "--timezone",
  249. "timezone_optional",
  250. help="Timezone as string, e.g. 'UTC' or 'Europe/Amsterdam' (defaults to FLEXMEASURES_TIMEZONE config setting)",
  251. )
  252. def new_user(
  253. username: str,
  254. email: str,
  255. account_id: int,
  256. roles: list[str],
  257. timezone_optional: str | None,
  258. ):
  259. """
  260. Create a FlexMeasures user.
  261. The `users create` task from Flask Security Too is too simple for us.
  262. Use this to add email, timezone and roles.
  263. """
  264. if timezone_optional is None:
  265. timezone = app.config.get("FLEXMEASURES_TIMEZONE", "UTC")
  266. click.secho(
  267. f"Setting user timezone to {timezone} (taken from FLEXMEASURES_TIMEZONE config setting)...",
  268. **MsgStyle.WARN,
  269. )
  270. else:
  271. timezone = timezone_optional
  272. try:
  273. pytz.timezone(timezone)
  274. except pytz.UnknownTimeZoneError:
  275. click.secho(f"Timezone {timezone} is unknown!", **MsgStyle.ERROR)
  276. raise click.Abort()
  277. account = db.session.get(Account, account_id)
  278. if account is None:
  279. click.secho(f"No account with ID {account_id} found!", **MsgStyle.ERROR)
  280. raise click.Abort()
  281. pwd1 = getpass.getpass(prompt="Please enter the password:")
  282. pwd2 = getpass.getpass(prompt="Please repeat the password:")
  283. if pwd1 != pwd2:
  284. click.secho("Passwords do not match!", **MsgStyle.ERROR)
  285. raise click.Abort()
  286. created_user = create_user(
  287. username=username,
  288. email=email,
  289. password=pwd1,
  290. account_name=account.name,
  291. timezone=timezone,
  292. user_roles=roles,
  293. check_email_deliverability=False,
  294. )
  295. db.session.commit()
  296. click.secho(f"Successfully created user {created_user}", **MsgStyle.SUCCESS)
  297. @fm_add_data.command("sensor", cls=DeprecatedOptionsCommand)
  298. @with_appcontext
  299. @click.option("--name", required=True)
  300. @click.option("--unit", required=True, help="e.g. °C, m/s, kW/m²")
  301. @click.option(
  302. "--event-resolution",
  303. required=True,
  304. type=str,
  305. help="Expected resolution of the data in ISO8601 duration string",
  306. )
  307. @click.option(
  308. "--timezone",
  309. required=True,
  310. help="Timezone as string, e.g. 'UTC' or 'Europe/Amsterdam'",
  311. )
  312. @click.option(
  313. "--asset",
  314. "--asset-id",
  315. "generic_asset_id",
  316. required=True,
  317. type=int,
  318. cls=DeprecatedOption,
  319. deprecated=["--asset-id"],
  320. preferred="--asset",
  321. help="Generic asset to assign this sensor to",
  322. )
  323. @click.option(
  324. "--attributes",
  325. required=False,
  326. type=str,
  327. default="{}",
  328. help='Additional attributes. Passed as JSON string, should be a dict. Hint: Currently, for sensors that measure power, use {"capacity_in_mw": 10} to set a capacity of 10 MW',
  329. )
  330. def add_sensor(**args):
  331. """Add a sensor."""
  332. check_timezone(args["timezone"])
  333. try:
  334. attributes = json.loads(args["attributes"])
  335. except json.decoder.JSONDecodeError as jde:
  336. click.secho(
  337. f"Error decoding --attributes. Please check your JSON: {jde}",
  338. **MsgStyle.ERROR,
  339. )
  340. raise click.Abort()
  341. del args["attributes"] # not part of schema
  342. if args["event_resolution"].isdigit():
  343. click.secho(
  344. "DeprecationWarning: Use ISO8601 duration string for event-resolution, minutes in int will be depricated from v0.16.0",
  345. **MsgStyle.WARN,
  346. )
  347. timedelta_event_resolution = timedelta(minutes=int(args["event_resolution"]))
  348. isodate_event_resolution = isodate.duration_isoformat(
  349. timedelta_event_resolution
  350. )
  351. args["event_resolution"] = isodate_event_resolution
  352. check_errors(SensorSchema().validate(args))
  353. sensor = Sensor(**args)
  354. if not isinstance(attributes, dict):
  355. click.secho("Attributes should be a dict.", **MsgStyle.ERROR)
  356. raise click.Abort()
  357. sensor.attributes = attributes
  358. db.session.add(sensor)
  359. db.session.commit()
  360. click.secho(f"Successfully created sensor with ID {sensor.id}", **MsgStyle.SUCCESS)
  361. click.secho(
  362. f"You can access it at its entity address {sensor.entity_address}",
  363. **MsgStyle.SUCCESS,
  364. )
  365. @fm_add_data.command("asset-type")
  366. @with_appcontext
  367. @click.option("--name", required=True)
  368. @click.option(
  369. "--description",
  370. type=str,
  371. help="Description (useful to explain acronyms, for example).",
  372. )
  373. def add_asset_type(**kwargs):
  374. """Add an asset type."""
  375. check_errors(GenericAssetTypeSchema().validate(kwargs))
  376. generic_asset_type = GenericAssetType(**kwargs)
  377. db.session.add(generic_asset_type)
  378. db.session.commit()
  379. click.secho(
  380. f"Successfully created asset type with ID {generic_asset_type.id}.",
  381. **MsgStyle.SUCCESS,
  382. )
  383. click.secho("You can now assign assets to it.", **MsgStyle.SUCCESS)
  384. @fm_add_data.command("asset", cls=DeprecatedOptionsCommand)
  385. @with_appcontext
  386. @click.option("--name", required=True)
  387. @click.option(
  388. "--latitude",
  389. type=LatitudeField(),
  390. help="Latitude of the asset's location",
  391. )
  392. @click.option(
  393. "--longitude",
  394. type=LongitudeField(),
  395. help="Longitude of the asset's location",
  396. )
  397. @click.option(
  398. "--account",
  399. "--account-id",
  400. "account_id",
  401. type=int,
  402. required=False,
  403. cls=DeprecatedOption,
  404. deprecated=["--account-id"],
  405. preferred="--account",
  406. help="Add asset to this account. Follow up with the account's ID. If not set, the asset will become public (which makes it accessible to all users).",
  407. )
  408. @click.option(
  409. "--asset-type",
  410. "--asset-type-id",
  411. "generic_asset_type_id",
  412. required=True,
  413. type=int,
  414. cls=DeprecatedOption,
  415. deprecated=["--asset-type-id"],
  416. preferred="--asset-type",
  417. help="Asset type to assign to this asset",
  418. )
  419. @click.option(
  420. "--parent-asset",
  421. "parent_asset_id",
  422. required=False,
  423. type=int,
  424. help="Parent of this asset. The entity needs to exists on the database.",
  425. )
  426. def add_asset(**args):
  427. """Add an asset."""
  428. check_errors(GenericAssetSchema().validate(args))
  429. generic_asset = GenericAsset(**args)
  430. if generic_asset.account_id is None:
  431. click.secho(
  432. "Creating a PUBLIC asset, as no --account-id is given ...",
  433. **MsgStyle.WARN,
  434. )
  435. db.session.add(generic_asset)
  436. db.session.commit()
  437. click.secho(
  438. f"Successfully created asset with ID {generic_asset.id}.", **MsgStyle.SUCCESS
  439. )
  440. click.secho("You can now assign sensors to it.", **MsgStyle.SUCCESS)
  441. @fm_add_data.command("initial-structure")
  442. @with_appcontext
  443. def add_initial_structure():
  444. """Initialize useful structural data."""
  445. populate_initial_structure(db)
  446. @fm_add_data.command("source")
  447. @with_appcontext
  448. @click.option(
  449. "--name",
  450. required=True,
  451. type=str,
  452. help="Name of the source (usually an organization)",
  453. )
  454. @click.option(
  455. "--model",
  456. required=False,
  457. type=str,
  458. help="Optionally, specify a model (for example, a class name, function name or url).",
  459. )
  460. @click.option(
  461. "--version",
  462. required=False,
  463. type=str,
  464. help="Optionally, specify a version (for example, '1.0'.",
  465. )
  466. @click.option(
  467. "--type",
  468. "source_type",
  469. required=True,
  470. type=str,
  471. help="Type of source (for example, 'forecaster' or 'scheduler').",
  472. )
  473. def add_source(name: str, model: str, version: str, source_type: str):
  474. source = get_or_create_source(
  475. source=name,
  476. model=model,
  477. version=version,
  478. source_type=source_type,
  479. )
  480. db.session.commit()
  481. click.secho(f"Added source {source.__repr__()}", **MsgStyle.SUCCESS)
  482. @fm_add_data.command("beliefs", cls=DeprecatedOptionsCommand)
  483. @with_appcontext
  484. @click.argument("file", type=click.Path(exists=True))
  485. @click.option(
  486. "--sensor",
  487. "--sensor-id",
  488. "sensor",
  489. required=True,
  490. type=SensorIdField(),
  491. cls=DeprecatedOption,
  492. deprecated=["--sensor-id"],
  493. preferred="--sensor",
  494. help="Record the beliefs under this sensor. Follow up with the sensor's ID. ",
  495. )
  496. @click.option(
  497. "--source",
  498. required=True,
  499. type=str,
  500. help="Source of the beliefs (an existing source id or name, or a new name).",
  501. )
  502. @click.option(
  503. "--unit",
  504. required=False,
  505. type=str,
  506. help="Unit of the data, for conversion to the sensor unit, if possible (a string unit such as 'kW' or 'm³/h').\n"
  507. "Measurements of time itself that are formatted as a 'datetime' or 'timedelta' can be converted to a sensor unit representing time (such as 's' or 'h'),\n"
  508. "where datetimes are represented as a duration with respect to the UNIX epoch."
  509. "Hint: to switch the sign of the data, prepend a minus sign.\n"
  510. "For example, when assigning kW consumption data to a kW production sensor, use '-kW'.",
  511. )
  512. @click.option(
  513. "--horizon",
  514. required=False,
  515. type=int,
  516. help="Belief horizon in minutes (use positive horizon for ex-ante beliefs or negative horizon for ex-post beliefs).",
  517. )
  518. @click.option(
  519. "--cp",
  520. required=False,
  521. type=click.FloatRange(0, 1),
  522. help="Cumulative probability in the range [0, 1].",
  523. )
  524. @click.option(
  525. "--resample/--do-not-resample",
  526. default=True,
  527. help="Resample the data to fit the sensor's event resolution. "
  528. " Only downsampling is currently supported (for example, from hourly to daily data).",
  529. )
  530. @click.option(
  531. "--allow-overwrite/--do-not-allow-overwrite",
  532. default=False,
  533. help="Allow overwriting possibly already existing data.\n"
  534. "Not allowing overwriting can be much more efficient",
  535. )
  536. @click.option(
  537. "--skiprows",
  538. required=False,
  539. default=1,
  540. type=int,
  541. help="Number of rows to skip from the top. Set to >1 to skip additional headers.",
  542. )
  543. @click.option(
  544. "--na-values",
  545. required=False,
  546. multiple=True,
  547. help="Additional strings to recognize as NaN values. This argument can be given multiple times.",
  548. )
  549. @click.option(
  550. "--keep-default-na",
  551. default=False,
  552. type=bool,
  553. help="Whether or not to keep NaN values in the data.",
  554. )
  555. @click.option(
  556. "--nrows",
  557. required=False,
  558. type=int,
  559. help="Number of rows to read (from the top, after possibly skipping rows). Leave out to read all rows.",
  560. )
  561. @click.option(
  562. "--datecol",
  563. required=False,
  564. default=0,
  565. type=int,
  566. help="Column number with datetimes (0 is 1st column, the default)",
  567. )
  568. @click.option(
  569. "--valuecol",
  570. required=False,
  571. default=1,
  572. type=int,
  573. help="Column number with values (1 is 2nd column, the default)",
  574. )
  575. @click.option(
  576. "--beliefcol",
  577. required=False,
  578. type=int,
  579. help="Column number with datetimes",
  580. )
  581. @click.option(
  582. "--timezone",
  583. required=False,
  584. default=None,
  585. help="Timezone as string, e.g. 'UTC' or 'Europe/Amsterdam'",
  586. )
  587. @click.option(
  588. "--filter-column",
  589. "filter_columns",
  590. multiple=True,
  591. help="Set a column number to filter data. Use together with --filter-value.",
  592. )
  593. @click.option(
  594. "--filter-value",
  595. "filter_values",
  596. multiple=True,
  597. help="Set a column value to filter data. Only rows with this value will be added. Use together with --filter-column.",
  598. )
  599. @click.option(
  600. "--delimiter",
  601. required=True,
  602. type=str,
  603. default=",",
  604. help="[For CSV files] Character to delimit columns per row, defaults to comma",
  605. )
  606. @click.option(
  607. "--decimal",
  608. required=False,
  609. default=".",
  610. type=str,
  611. help="[For CSV files] decimal character, e.g. '.' for 10.5",
  612. )
  613. @click.option(
  614. "--thousands",
  615. required=False,
  616. default=None,
  617. type=str,
  618. help="[For CSV files] thousands separator, e.g. '.' for 10.035,2",
  619. )
  620. @click.option(
  621. "--sheet_number",
  622. required=False,
  623. type=int,
  624. help="[For xls or xlsx files] Sheet number with the data (0 is 1st sheet)",
  625. )
  626. def add_beliefs(
  627. file: str,
  628. sensor: Sensor,
  629. source: str,
  630. filter_columns: list[int],
  631. filter_values: list[int],
  632. unit: str | None = None,
  633. horizon: int | None = None,
  634. cp: float | None = None,
  635. resample: bool = True,
  636. allow_overwrite: bool = False,
  637. skiprows: int = 1,
  638. na_values: list[str] | None = None,
  639. keep_default_na: bool = False,
  640. nrows: int | None = None,
  641. datecol: int = 0,
  642. valuecol: int = 1,
  643. beliefcol: int | None = None,
  644. timezone: str | None = None,
  645. delimiter: str = ",",
  646. decimal: str = ".",
  647. thousands: str | None = None,
  648. sheet_number: int | None = None,
  649. **kwargs, # in-code calls to this CLI command can set additional kwargs for use in pandas.read_csv or pandas.read_excel
  650. ):
  651. """Add sensor data from a CSV or Excel file.
  652. To use default settings, structure your CSV file as follows:
  653. - One header line (will be ignored!)
  654. - UTC datetimes in 1st column
  655. - values in 2nd column
  656. For example:
  657. Date,Inflow (cubic meter)
  658. 2020-12-03 14:00,212
  659. 2020-12-03 14:10,215.6
  660. 2020-12-03 14:20,203.8
  661. In case no --horizon is specified and no beliefcol is specified,
  662. the moment of executing this CLI command is taken as the time at which the beliefs were recorded.
  663. """
  664. _source = parse_source(source)
  665. # Set up optional parameters for read_csv
  666. if file.split(".")[-1].lower() == "csv":
  667. kwargs["delimiter"] = delimiter
  668. kwargs["decimal"] = decimal
  669. kwargs["thousands"] = thousands
  670. if sheet_number is not None:
  671. kwargs["sheet_name"] = sheet_number
  672. if horizon is not None:
  673. kwargs["belief_horizon"] = timedelta(minutes=horizon)
  674. elif beliefcol is None:
  675. kwargs["belief_time"] = server_now().astimezone(pytz.timezone(sensor.timezone))
  676. # Set up optional filters:
  677. if len(filter_columns) != len(filter_values):
  678. raise ValueError(
  679. "The number of filter columns and filter values should be the same."
  680. )
  681. filter_by_column = (
  682. dict(zip(filter_columns, filter_values)) if filter_columns else None
  683. )
  684. bdf = tb.read_csv(
  685. file,
  686. sensor,
  687. source=_source,
  688. cumulative_probability=cp,
  689. resample=resample,
  690. header=None,
  691. skiprows=skiprows,
  692. nrows=nrows,
  693. usecols=(
  694. [datecol, valuecol] if beliefcol is None else [datecol, beliefcol, valuecol]
  695. ),
  696. parse_dates=True,
  697. na_values=na_values,
  698. keep_default_na=keep_default_na,
  699. timezone=timezone,
  700. filter_by_column=filter_by_column,
  701. **kwargs,
  702. )
  703. duplicate_rows = bdf.index.duplicated(keep="first")
  704. if any(duplicate_rows) > 0:
  705. click.secho(
  706. "Duplicates found. Dropping duplicates for the following records:",
  707. **MsgStyle.WARN,
  708. )
  709. click.secho(bdf[duplicate_rows], **MsgStyle.WARN)
  710. bdf = bdf[~duplicate_rows]
  711. if unit is not None:
  712. bdf["event_value"] = convert_units(
  713. bdf["event_value"],
  714. from_unit=unit,
  715. to_unit=sensor.unit,
  716. event_resolution=sensor.event_resolution,
  717. )
  718. try:
  719. TimedBelief.add(
  720. bdf,
  721. expunge_session=True,
  722. allow_overwrite=allow_overwrite,
  723. bulk_save_objects=True,
  724. commit_transaction=True,
  725. )
  726. click.secho(f"Successfully created beliefs\n{bdf}", **MsgStyle.SUCCESS)
  727. except IntegrityError as e:
  728. db.session.rollback()
  729. click.secho(
  730. f"Failed to create beliefs due to the following error: {e.orig}",
  731. **MsgStyle.ERROR,
  732. )
  733. if not allow_overwrite:
  734. click.secho(
  735. "As a possible workaround, use the --allow-overwrite flag.",
  736. **MsgStyle.ERROR,
  737. )
  738. @fm_add_data.command("annotation", cls=DeprecatedOptionsCommand)
  739. @with_appcontext
  740. @click.option(
  741. "--content",
  742. required=True,
  743. prompt="Enter annotation",
  744. )
  745. @click.option(
  746. "--at",
  747. "start_str",
  748. required=True,
  749. help="Annotation is set (or starts) at this datetime. Follow up with a timezone-aware datetime in ISO 6801 format.",
  750. )
  751. @click.option(
  752. "--until",
  753. "end_str",
  754. required=False,
  755. help="Annotation ends at this datetime. Follow up with a timezone-aware datetime in ISO 6801 format. Defaults to one (nominal) day after the start of the annotation.",
  756. )
  757. @click.option(
  758. "--account",
  759. "--account-id",
  760. "account_ids",
  761. type=click.INT,
  762. multiple=True,
  763. cls=DeprecatedOption,
  764. deprecated=["--account-id"],
  765. preferred="--account",
  766. help="Add annotation to this organisation account. Follow up with the account's ID. This argument can be given multiple times.",
  767. )
  768. @click.option(
  769. "--asset",
  770. "--asset-id",
  771. "generic_asset_ids",
  772. type=int,
  773. multiple=True,
  774. cls=DeprecatedOption,
  775. deprecated=["--asset-id"],
  776. preferred="--asset",
  777. help="Add annotation to this asset. Follow up with the asset's ID. This argument can be given multiple times.",
  778. )
  779. @click.option(
  780. "--sensor",
  781. "--sensor-id",
  782. "sensor_ids",
  783. type=int,
  784. multiple=True,
  785. cls=DeprecatedOption,
  786. deprecated=["--sensor-id"],
  787. preferred="--sensor",
  788. help="Add annotation to this sensor. Follow up with the sensor's ID. This argument can be given multiple times.",
  789. )
  790. @click.option(
  791. "--user",
  792. "--user-id",
  793. "user_id",
  794. type=int,
  795. required=True,
  796. cls=DeprecatedOption,
  797. deprecated=["--user-id"],
  798. preferred="--user",
  799. help="Attribute annotation to this user. Follow up with the user's ID.",
  800. )
  801. def add_annotation(
  802. content: str,
  803. start_str: str,
  804. end_str: str | None,
  805. account_ids: list[int],
  806. generic_asset_ids: list[int],
  807. sensor_ids: list[int],
  808. user_id: int,
  809. ):
  810. """Add annotation to accounts, assets and/or sensors."""
  811. # Parse input
  812. start = pd.Timestamp(start_str)
  813. end = (
  814. pd.Timestamp(end_str)
  815. if end_str is not None
  816. else start + pd.offsets.DateOffset(days=1)
  817. )
  818. accounts = (
  819. db.session.scalars(select(Account).filter(Account.id.in_(account_ids))).all()
  820. if account_ids
  821. else []
  822. )
  823. assets = (
  824. db.session.scalars(
  825. select(GenericAsset).filter(GenericAsset.id.in_(generic_asset_ids))
  826. ).all()
  827. if generic_asset_ids
  828. else []
  829. )
  830. sensors = (
  831. db.session.scalars(select(Sensor).filter(Sensor.id.in_(sensor_ids))).all()
  832. if sensor_ids
  833. else []
  834. )
  835. user = db.session.get(User, user_id)
  836. _source = get_or_create_source(user)
  837. # Create annotation
  838. annotation = get_or_create_annotation(
  839. Annotation(
  840. content=content,
  841. start=start,
  842. end=end,
  843. source=_source,
  844. type="label",
  845. )
  846. )
  847. for account in accounts:
  848. account.annotations.append(annotation)
  849. for asset in assets:
  850. asset.annotations.append(annotation)
  851. for sensor in sensors:
  852. sensor.annotations.append(annotation)
  853. db.session.commit()
  854. click.secho("Successfully added annotation.", **MsgStyle.SUCCESS)
  855. @fm_add_data.command("holidays", cls=DeprecatedOptionsCommand)
  856. @with_appcontext
  857. @click.option(
  858. "--year",
  859. type=click.INT,
  860. help="The year for which to look up holidays",
  861. )
  862. @click.option(
  863. "--country",
  864. "countries",
  865. type=click.STRING,
  866. multiple=True,
  867. help="The ISO 3166-1 country/region or ISO 3166-2 sub-region for which to look up holidays (such as US, BR and DE). This argument can be given multiple times.",
  868. )
  869. @click.option(
  870. "--asset",
  871. "--asset-id",
  872. "generic_asset_ids",
  873. type=click.INT,
  874. multiple=True,
  875. cls=DeprecatedOption,
  876. deprecated=["--asset-id"],
  877. preferred="--asset",
  878. help="Add annotations to this asset. Follow up with the asset's ID. This argument can be given multiple times.",
  879. )
  880. @click.option(
  881. "--account",
  882. "--account-id",
  883. "account_ids",
  884. type=click.INT,
  885. multiple=True,
  886. cls=DeprecatedOption,
  887. deprecated=["--account-id"],
  888. preferred="--account",
  889. help="Add annotations to this account. Follow up with the account's ID. This argument can be given multiple times.",
  890. )
  891. def add_holidays(
  892. year: int,
  893. countries: list[str],
  894. generic_asset_ids: list[int],
  895. account_ids: list[int],
  896. ):
  897. """Add holiday annotations to accounts and/or assets."""
  898. calendars = workalendar_registry.get_calendars(countries)
  899. num_holidays = {}
  900. accounts = (
  901. db.session.scalars(select(Account).filter(Account.id.in_(account_ids))).all()
  902. if account_ids
  903. else []
  904. )
  905. assets = (
  906. db.session.scalars(
  907. select(GenericAsset).filter(GenericAsset.id.in_(generic_asset_ids))
  908. ).all()
  909. if generic_asset_ids
  910. else []
  911. )
  912. annotations = []
  913. for country, calendar in calendars.items():
  914. _source = get_or_create_source(
  915. "workalendar", model=country, source_type="CLI script"
  916. )
  917. holidays = calendar().holidays(year)
  918. for holiday in holidays:
  919. start = pd.Timestamp(holiday[0])
  920. end = start + pd.offsets.DateOffset(days=1)
  921. annotations.append(
  922. get_or_create_annotation(
  923. Annotation(
  924. content=holiday[1],
  925. start=start,
  926. end=end,
  927. source=_source,
  928. type="holiday",
  929. )
  930. )
  931. )
  932. num_holidays[country] = len(holidays)
  933. db.session.add_all(annotations)
  934. for account in accounts:
  935. account.annotations += annotations
  936. for asset in assets:
  937. asset.annotations += annotations
  938. db.session.commit()
  939. click.secho(
  940. f"Successfully added holidays to {len(accounts)} {flexmeasures_inflection.pluralize('account', len(accounts))} and {len(assets)} {flexmeasures_inflection.pluralize('asset', len(assets))}:\n{num_holidays}",
  941. **MsgStyle.SUCCESS,
  942. )
  943. @fm_add_data.command("forecasts", cls=DeprecatedOptionsCommand)
  944. @with_appcontext
  945. @click.option(
  946. "--sensor",
  947. "--sensor-id",
  948. "sensor_ids",
  949. multiple=True,
  950. required=True,
  951. cls=DeprecatedOption,
  952. deprecated=["--sensor-id"],
  953. preferred="--sensor",
  954. help="Create forecasts for this sensor. Follow up with the sensor's ID. This argument can be given multiple times.",
  955. )
  956. @click.option(
  957. "--from-date",
  958. "from_date_str",
  959. default="2015-02-08",
  960. help="Forecast from date (inclusive). Follow up with a date in the form yyyy-mm-dd.",
  961. )
  962. @click.option(
  963. "--to-date",
  964. "to_date_str",
  965. default="2015-12-31",
  966. help="Forecast to date (inclusive). Follow up with a date in the form yyyy-mm-dd.",
  967. )
  968. @click.option(
  969. "--resolution",
  970. type=int,
  971. help="Resolution of forecast in minutes. If not set, resolution is determined from the sensor to be forecasted",
  972. )
  973. @click.option(
  974. "--horizon",
  975. "horizons_as_hours",
  976. multiple=True,
  977. type=click.Choice(["1", "6", "24", "48"]),
  978. default=["1", "6", "24", "48"],
  979. help="Forecasting horizon in hours. This argument can be given multiple times. Defaults to all possible horizons.",
  980. )
  981. @click.option(
  982. "--as-job",
  983. is_flag=True,
  984. help="Whether to queue a forecasting job instead of computing directly. "
  985. "To process the job, run a worker (on any computer, but configured to the same databases) to process the 'forecasting' queue. Defaults to False.",
  986. )
  987. def create_forecasts(
  988. sensor_ids: list[int],
  989. from_date_str: str = "2015-02-08",
  990. to_date_str: str = "2015-12-31",
  991. horizons_as_hours: list[str] = ["1"],
  992. resolution: int | None = None,
  993. as_job: bool = False,
  994. ):
  995. """
  996. Create forecasts.
  997. For example:
  998. --from-date 2015-02-02 --to-date 2015-02-04 --horizon 6 --sensor 12 --sensor 14
  999. This will create forecast values from 0am on May 2nd to 0am on May 5th,
  1000. based on a 6-hour horizon, for sensors 12 and 14.
  1001. """
  1002. # make horizons
  1003. horizons = [timedelta(hours=int(h)) for h in horizons_as_hours]
  1004. # apply timezone and set forecast_end to be an inclusive version of to_date
  1005. timezone = app.config.get("FLEXMEASURES_TIMEZONE")
  1006. forecast_start = pd.Timestamp(from_date_str).tz_localize(timezone)
  1007. forecast_end = (pd.Timestamp(to_date_str) + pd.Timedelta("1D")).tz_localize(
  1008. timezone
  1009. )
  1010. event_resolution: timedelta | None
  1011. if resolution is not None:
  1012. event_resolution = timedelta(minutes=resolution)
  1013. else:
  1014. event_resolution = None
  1015. if as_job:
  1016. num_jobs = 0
  1017. for sensor_id in sensor_ids:
  1018. for horizon in horizons:
  1019. # Note that this time period refers to the period of events we are forecasting, while in create_forecasting_jobs
  1020. # the time period refers to the period of belief_times, therefore we are subtracting the horizon.
  1021. jobs = create_forecasting_jobs(
  1022. sensor_id=sensor_id,
  1023. horizons=[horizon],
  1024. start_of_roll=forecast_start - horizon,
  1025. end_of_roll=forecast_end - horizon,
  1026. )
  1027. num_jobs += len(jobs)
  1028. click.secho(
  1029. f"{num_jobs} new forecasting job(s) added to the queue.",
  1030. **MsgStyle.SUCCESS,
  1031. )
  1032. else:
  1033. from flexmeasures.data.scripts.data_gen import populate_time_series_forecasts
  1034. populate_time_series_forecasts( # this function reports its own output
  1035. db=app.db,
  1036. sensor_ids=sensor_ids,
  1037. horizons=horizons,
  1038. forecast_start=forecast_start,
  1039. forecast_end=forecast_end,
  1040. event_resolution=event_resolution,
  1041. )
  1042. # todo: repurpose `flexmeasures add schedule` (deprecated since v0.12),
  1043. # - see https://github.com/FlexMeasures/flexmeasures/pull/537#discussion_r1048680231
  1044. # - hint for repurposing to invoke custom logic instead of a default subcommand:
  1045. # @fm_add_data.group("schedule", invoke_without_command=True)
  1046. # def create_schedule():
  1047. # if ctx.invoked_subcommand:
  1048. # ...
  1049. @fm_add_data.group(
  1050. "schedule",
  1051. cls=DeprecatedDefaultGroup,
  1052. default="storage",
  1053. deprecation_message="The command 'flexmeasures add schedule' is deprecated. Please use `flexmeasures add schedule for-storage` instead.",
  1054. )
  1055. @click.pass_context
  1056. @with_appcontext
  1057. def create_schedule(ctx):
  1058. """(Deprecated) Create a new schedule for a given power sensor.
  1059. THIS COMMAND HAS BEEN RENAMED TO `flexmeasures add schedule for-storage`
  1060. """
  1061. pass
  1062. @create_schedule.command("for-storage", cls=DeprecatedOptionsCommand)
  1063. @with_appcontext
  1064. @click.option(
  1065. "--sensor",
  1066. "--sensor-id",
  1067. "power_sensor",
  1068. type=SensorIdField(),
  1069. required=True,
  1070. cls=DeprecatedOption,
  1071. deprecated=["--sensor-id"],
  1072. preferred="--sensor",
  1073. help="Create schedule for this sensor. Should be a power sensor. Follow up with the sensor's ID.",
  1074. )
  1075. @click.option(
  1076. "--consumption-price-sensor",
  1077. "consumption_price_sensor",
  1078. type=SensorIdField(),
  1079. required=False,
  1080. help="Optimize consumption against this sensor. The sensor typically records an electricity price (e.g. in EUR/kWh), but this field can also be used to optimize against some emission intensity factor (e.g. in kg CO₂ eq./kWh). Follow up with the sensor's ID.",
  1081. )
  1082. @click.option(
  1083. "--production-price-sensor",
  1084. "production_price_sensor",
  1085. type=SensorIdField(),
  1086. required=False,
  1087. help="Optimize production against this sensor. Defaults to the consumption price sensor. The sensor typically records an electricity price (e.g. in EUR/kWh), but this field can also be used to optimize against some emission intensity factor (e.g. in kg CO₂ eq./kWh). Follow up with the sensor's ID.",
  1088. )
  1089. @click.option(
  1090. "--optimization-context-id",
  1091. "optimization_context_sensor",
  1092. type=SensorIdField(),
  1093. required=False,
  1094. help="To be deprecated. Use consumption-price-sensor instead.",
  1095. )
  1096. @click.option(
  1097. "--inflexible-device-sensor",
  1098. "inflexible_device_sensors",
  1099. type=SensorIdField(),
  1100. multiple=True,
  1101. help="Take into account the power flow of inflexible devices. Follow up with the sensor's ID."
  1102. " This argument can be given multiple times.",
  1103. )
  1104. @click.option(
  1105. "--site-power-capacity",
  1106. "site_power_capacity",
  1107. type=VariableQuantityField("MW"),
  1108. required=False,
  1109. default=None,
  1110. help="Site consumption/production power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)"
  1111. "or reference a sensor using 'sensor:<id>' (e.g. sensor:34)."
  1112. "It defines both-ways maximum power capacity on the site level.",
  1113. )
  1114. @click.option(
  1115. "--site-consumption-capacity",
  1116. "site_consumption_capacity",
  1117. type=VariableQuantityField("MW"),
  1118. required=False,
  1119. default=None,
  1120. help="Site consumption power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)"
  1121. "or reference a sensor using 'sensor:<id>' (e.g. sensor:34)."
  1122. "It defines the maximum consumption capacity on the site level.",
  1123. )
  1124. @click.option(
  1125. "--site-production-capacity",
  1126. "site_production_capacity",
  1127. type=VariableQuantityField("MW"),
  1128. required=False,
  1129. default=None,
  1130. help="Site production power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)"
  1131. "or reference a sensor using 'sensor:<id>' (e.g. sensor:34)."
  1132. "It defines the maximum production capacity on the site level.",
  1133. )
  1134. @click.option(
  1135. "--start",
  1136. "start",
  1137. type=AwareDateTimeField(format="iso"),
  1138. required=True,
  1139. help="Schedule starts at this datetime. Follow up with a timezone-aware datetime in ISO 6801 format.",
  1140. )
  1141. @click.option(
  1142. "--duration",
  1143. "duration",
  1144. type=DurationField(),
  1145. required=True,
  1146. help="Duration of schedule, after --start. Follow up with a duration in ISO 6801 format, e.g. PT1H (1 hour) or PT45M (45 minutes).",
  1147. )
  1148. @click.option(
  1149. "--soc-at-start",
  1150. "soc_at_start",
  1151. type=QuantityField("%", validate=validate.Range(min=0, max=1)),
  1152. required=True,
  1153. help="State of charge (e.g 32.8%, or 0.328) at the start of the schedule.",
  1154. )
  1155. @click.option(
  1156. "--state-of-charge",
  1157. "state_of_charge",
  1158. type=SensorIdField(unit="MWh"),
  1159. help="State of charge sensor.",
  1160. required=False,
  1161. default=None,
  1162. )
  1163. @click.option(
  1164. "--soc-target",
  1165. "soc_target_strings",
  1166. type=click.Tuple(
  1167. types=[QuantityField("%", validate=validate.Range(min=0, max=1)), str]
  1168. ),
  1169. multiple=True,
  1170. required=False,
  1171. help="Target state of charge (e.g 100%, or 1) at some datetime. Follow up with a float value and a timezone-aware datetime in ISO 8601 format."
  1172. " This argument can be given multiple times."
  1173. " For example: --soc-target 100% 2022-02-23T13:40:52+00:00",
  1174. )
  1175. @click.option(
  1176. "--soc-min",
  1177. "soc_min",
  1178. type=QuantityField("%", validate=validate.Range(min=0, max=1)),
  1179. required=False,
  1180. help="Minimum state of charge (e.g 20%, or 0.2) for the schedule.",
  1181. )
  1182. @click.option(
  1183. "--soc-max",
  1184. "soc_max",
  1185. type=QuantityField("%", validate=validate.Range(min=0, max=1)),
  1186. required=False,
  1187. help="Maximum state of charge (e.g 80%, or 0.8) for the schedule.",
  1188. )
  1189. @click.option(
  1190. "--roundtrip-efficiency",
  1191. "roundtrip_efficiency",
  1192. type=EfficiencyField(),
  1193. required=False,
  1194. default=1,
  1195. help="Round-trip efficiency (e.g. 85% or 0.85) to use for the schedule. Defaults to 100% (no losses).",
  1196. )
  1197. @click.option(
  1198. "--charging-efficiency",
  1199. "charging_efficiency",
  1200. type=VariableQuantityField("%"),
  1201. required=False,
  1202. default=None,
  1203. help="Storage charging efficiency to use for the schedule."
  1204. "Provide a quantity with units (e.g. 94%) or a sensor storing the value with the syntax sensor:<id> (e.g. sensor:20)."
  1205. "Defaults to 100% (no losses).",
  1206. )
  1207. @click.option(
  1208. "--discharging-efficiency",
  1209. "discharging_efficiency",
  1210. type=VariableQuantityField("%"),
  1211. required=False,
  1212. default=None,
  1213. help="Storage discharging efficiency to use for the schedule."
  1214. "Provide a quantity with units (e.g. 94%) or a sensor storing the value with the syntax sensor:<id> (e.g. sensor:20)."
  1215. "Defaults to 100% (no losses).",
  1216. )
  1217. @click.option(
  1218. "--soc-gain",
  1219. "soc_gain",
  1220. type=VariableQuantityField("MW"),
  1221. required=False,
  1222. default=None,
  1223. help="Specify the State of Charge (SoC) gain as a quantity in power units (e.g. 1 MW or 1000 kW)"
  1224. "or reference a sensor by using 'sensor:<id>' (e.g. sensor:34)."
  1225. "This represents the rate at which storage is charged from a different source.",
  1226. )
  1227. @click.option(
  1228. "--soc-usage",
  1229. "soc_usage",
  1230. type=VariableQuantityField("MW"),
  1231. required=False,
  1232. default=None,
  1233. help="Specify the State of Charge (SoC) usage as a quantity in power units (e.g. 1 MW or 1000 kW) "
  1234. "or reference a sensor by using 'sensor:<id>' (e.g. sensor:34)."
  1235. "This represents the rate at which the storage is discharged from a different source.",
  1236. )
  1237. @click.option(
  1238. "--storage-power-capacity",
  1239. "storage_power_capacity",
  1240. type=VariableQuantityField("MW"),
  1241. required=False,
  1242. default=None,
  1243. help="Storage consumption/production power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)"
  1244. "or reference a sensor using 'sensor:<id>' (e.g. sensor:34)."
  1245. "It defines both-ways maximum power capacity.",
  1246. )
  1247. @click.option(
  1248. "--storage-consumption-capacity",
  1249. "storage_consumption_capacity",
  1250. type=VariableQuantityField("MW"),
  1251. required=False,
  1252. default=None,
  1253. help="Storage consumption power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)"
  1254. "or reference a sensor using 'sensor:<id>' (e.g. sensor:34)."
  1255. "It defines the storage maximum consumption (charging) capacity.",
  1256. )
  1257. @click.option(
  1258. "--storage-production-capacity",
  1259. "storage_production_capacity",
  1260. type=VariableQuantityField("MW"),
  1261. required=False,
  1262. default=None,
  1263. help="Storage production power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)"
  1264. "or reference a sensor using 'sensor:<id>' (e.g. sensor:34)."
  1265. "It defines the storage maximum production (discharging) capacity.",
  1266. )
  1267. @click.option(
  1268. "--storage-efficiency",
  1269. "storage_efficiency",
  1270. type=VariableQuantityField("%", default_src_unit="dimensionless"),
  1271. required=False,
  1272. default="100%",
  1273. help="Storage efficiency (e.g. 95% or 0.95) to use for the schedule,"
  1274. " applied over each time step equal to the sensor resolution."
  1275. "This parameter also supports using a reference sensor as 'sensor:<id>' (e.g. sensor:34)."
  1276. " For example, a storage efficiency of 99 percent per (absolute) day, for scheduling a 1-hour resolution sensor, should be passed as a storage efficiency of 0.99**(1/24)."
  1277. " Defaults to 100% (no losses).",
  1278. )
  1279. @click.option(
  1280. "--as-job",
  1281. is_flag=True,
  1282. help="Whether to queue a scheduling job instead of computing directly. "
  1283. "To process the job, run a worker (on any computer, but configured to the same databases) to process the 'scheduling' queue. Defaults to False.",
  1284. )
  1285. def add_schedule_for_storage( # noqa C901
  1286. power_sensor: Sensor,
  1287. consumption_price_sensor: Sensor,
  1288. production_price_sensor: Sensor,
  1289. optimization_context_sensor: Sensor,
  1290. inflexible_device_sensors: list[Sensor],
  1291. site_power_capacity: ur.Quantity | Sensor | None,
  1292. site_consumption_capacity: ur.Quantity | Sensor | None,
  1293. site_production_capacity: ur.Quantity | Sensor | None,
  1294. start: datetime,
  1295. duration: timedelta,
  1296. soc_at_start: ur.Quantity,
  1297. charging_efficiency: ur.Quantity | Sensor | None,
  1298. discharging_efficiency: ur.Quantity | Sensor | None,
  1299. soc_gain: ur.Quantity | Sensor | None,
  1300. soc_usage: ur.Quantity | Sensor | None,
  1301. storage_power_capacity: ur.Quantity | Sensor | None,
  1302. storage_consumption_capacity: ur.Quantity | Sensor | None,
  1303. storage_production_capacity: ur.Quantity | Sensor | None,
  1304. soc_target_strings: list[tuple[ur.Quantity, str]],
  1305. soc_min: ur.Quantity | None = None,
  1306. soc_max: ur.Quantity | None = None,
  1307. roundtrip_efficiency: ur.Quantity | None = None,
  1308. storage_efficiency: ur.Quantity | Sensor | None = None,
  1309. state_of_charge: Sensor | None = None,
  1310. as_job: bool = False,
  1311. ):
  1312. """Create a new schedule for a storage asset.
  1313. Current limitations:
  1314. - Limited to power sensors (probably possible to generalize to non-electric assets)
  1315. - Only supports datetimes on the hour or a multiple of the sensor resolution thereafter
  1316. """
  1317. # todo: deprecate the 'optimization-context-id' argument in favor of 'consumption-price-sensor' (announced v0.11.0)
  1318. tb_utils.replace_deprecated_argument(
  1319. "optimization-context-id",
  1320. optimization_context_sensor,
  1321. "consumption-price-sensor",
  1322. consumption_price_sensor,
  1323. required_argument=False,
  1324. )
  1325. # Parse input and required sensor attributes
  1326. if not power_sensor.measures_power:
  1327. click.secho(
  1328. f"Sensor with ID {power_sensor.id} is not a power sensor.",
  1329. **MsgStyle.ERROR,
  1330. )
  1331. raise click.Abort()
  1332. if production_price_sensor is None and consumption_price_sensor is not None:
  1333. production_price_sensor = consumption_price_sensor
  1334. end = start + duration
  1335. # Convert SoC units (we ask for % in this CLI) to MWh, given the storage capacity
  1336. try:
  1337. check_required_attributes(power_sensor, [("max_soc_in_mwh", float)])
  1338. except MissingAttributeException:
  1339. click.secho(
  1340. f"Sensor {power_sensor} has no max_soc_in_mwh attribute.", **MsgStyle.ERROR
  1341. )
  1342. raise click.Abort()
  1343. capacity_str = f"{power_sensor.get_attribute('max_soc_in_mwh')} MWh"
  1344. soc_at_start = convert_units(soc_at_start.magnitude, soc_at_start.units, "MWh", capacity=capacity_str) # type: ignore
  1345. soc_targets = []
  1346. for soc_target_tuple in soc_target_strings:
  1347. soc_target_value_str, soc_target_datetime_str = soc_target_tuple
  1348. soc_target_value = convert_units(
  1349. soc_target_value_str.magnitude,
  1350. str(soc_target_value_str.units),
  1351. "MWh",
  1352. capacity=capacity_str,
  1353. )
  1354. soc_targets.append(
  1355. dict(value=soc_target_value, datetime=soc_target_datetime_str)
  1356. )
  1357. if soc_min is not None:
  1358. soc_min = convert_units(soc_min.magnitude, str(soc_min.units), "MWh", capacity=capacity_str) # type: ignore
  1359. if soc_max is not None:
  1360. soc_max = convert_units(soc_max.magnitude, str(soc_max.units), "MWh", capacity=capacity_str) # type: ignore
  1361. if roundtrip_efficiency is not None:
  1362. roundtrip_efficiency = roundtrip_efficiency.magnitude / 100.0
  1363. scheduling_kwargs = dict(
  1364. start=start,
  1365. end=end,
  1366. belief_time=server_now(),
  1367. resolution=power_sensor.event_resolution,
  1368. flex_model={
  1369. "soc-at-start": soc_at_start,
  1370. "soc-targets": soc_targets,
  1371. "soc-min": soc_min,
  1372. "soc-max": soc_max,
  1373. "soc-unit": "MWh",
  1374. "roundtrip-efficiency": roundtrip_efficiency,
  1375. },
  1376. flex_context={
  1377. "consumption-price": (
  1378. {"sensor": consumption_price_sensor.id}
  1379. if consumption_price_sensor
  1380. else None
  1381. ),
  1382. "production-price": (
  1383. {"sensor": production_price_sensor.id}
  1384. if production_price_sensor
  1385. else None
  1386. ),
  1387. "inflexible-device-sensors": [s.id for s in inflexible_device_sensors],
  1388. },
  1389. )
  1390. # remove None value from flex_context
  1391. scheduling_kwargs["flex_context"] = {
  1392. k: v for k, v in scheduling_kwargs["flex_context"].items() if v is not None
  1393. }
  1394. if state_of_charge is not None:
  1395. scheduling_kwargs["flex_model"]["state-of-charge"] = {
  1396. "sensor": state_of_charge.id
  1397. }
  1398. quantity_or_sensor_vars = {
  1399. "flex_model": {
  1400. "charging-efficiency": charging_efficiency,
  1401. "discharging-efficiency": discharging_efficiency,
  1402. "storage-efficiency": storage_efficiency,
  1403. "soc-gain": soc_gain,
  1404. "soc-usage": soc_usage,
  1405. "power-capacity": storage_power_capacity,
  1406. "consumption-capacity": storage_consumption_capacity,
  1407. "production-capacity": storage_production_capacity,
  1408. },
  1409. "flex_context": {
  1410. "site-power-capacity": site_power_capacity,
  1411. "site-consumption-capacity": site_consumption_capacity,
  1412. "site-production-capacity": site_production_capacity,
  1413. },
  1414. }
  1415. for key in ["flex_model", "flex_context"]:
  1416. for field_name, value in quantity_or_sensor_vars[key].items():
  1417. if value is not None:
  1418. if "efficiency" in field_name:
  1419. unit = "%"
  1420. else:
  1421. unit = "MW"
  1422. scheduling_kwargs[key][field_name] = VariableQuantityField(
  1423. unit
  1424. )._serialize(value, None, None)
  1425. if as_job:
  1426. job = create_scheduling_job(asset_or_sensor=power_sensor, **scheduling_kwargs)
  1427. if job:
  1428. click.secho(
  1429. f"New scheduling job {job.id} has been added to the queue.",
  1430. **MsgStyle.SUCCESS,
  1431. )
  1432. else:
  1433. success = make_schedule(
  1434. asset_or_sensor=get_asset_or_sensor_ref(power_sensor),
  1435. **scheduling_kwargs,
  1436. )
  1437. if success:
  1438. click.secho("New schedule is stored.", **MsgStyle.SUCCESS)
  1439. @create_schedule.command("for-process", cls=DeprecatedOptionsCommand)
  1440. @with_appcontext
  1441. @click.option(
  1442. "--sensor",
  1443. "--sensor-id",
  1444. "power_sensor",
  1445. type=SensorIdField(),
  1446. required=True,
  1447. cls=DeprecatedOption,
  1448. deprecated=["--sensor-id"],
  1449. preferred="--sensor",
  1450. help="Create schedule for this sensor. Should be a power sensor. Follow up with the sensor's ID.",
  1451. )
  1452. @click.option(
  1453. "--consumption-price-sensor",
  1454. "consumption_price_sensor",
  1455. type=SensorIdField(),
  1456. required=False,
  1457. help="Optimize consumption against this sensor. The sensor typically records an electricity price (e.g. in EUR/kWh), but this field can also be used to optimize against some emission intensity factor (e.g. in kg CO₂ eq./kWh). Follow up with the sensor's ID.",
  1458. )
  1459. @click.option(
  1460. "--start",
  1461. "start",
  1462. type=AwareDateTimeField(format="iso"),
  1463. required=True,
  1464. help="Schedule starts at this datetime. Follow up with a timezone-aware datetime in ISO 6801 format.",
  1465. )
  1466. @click.option(
  1467. "--duration",
  1468. "duration",
  1469. type=DurationField(),
  1470. required=True,
  1471. help="Duration of schedule, after --start. Follow up with a duration in ISO 6801 format, e.g. PT1H (1 hour) or PT45M (45 minutes).",
  1472. )
  1473. @click.option(
  1474. "--process-duration",
  1475. "process_duration",
  1476. type=DurationField(),
  1477. required=True,
  1478. help="Duration of the process. Follow up with a duration in ISO 6801 format, e.g. PT1H (1 hour) or PT45M (45 minutes).",
  1479. )
  1480. @click.option(
  1481. "--process-type",
  1482. "process_type",
  1483. type=click.Choice(["INFLEXIBLE", "BREAKABLE", "SHIFTABLE"], case_sensitive=False),
  1484. required=False,
  1485. default="SHIFTABLE",
  1486. help="Process schedule policy: INFLEXIBLE, BREAKABLE or SHIFTABLE.",
  1487. )
  1488. @click.option(
  1489. "--process-power",
  1490. "process_power",
  1491. type=ur.Quantity,
  1492. required=True,
  1493. help="Constant power of the process during the activation period, e.g. 4kW.",
  1494. )
  1495. @click.option(
  1496. "--forbid",
  1497. type=TimeIntervalField(),
  1498. multiple=True,
  1499. required=False,
  1500. help="Add time restrictions to the optimization, where the load will not be scheduled into."
  1501. 'Use the following format to define the restrictions: `{"start":<timezone-aware datetime in ISO 6801>, "duration":<ISO 6801 duration>}`'
  1502. "This options allows to define multiple time restrictions by using the --forbid for different periods.",
  1503. )
  1504. @click.option(
  1505. "--as-job",
  1506. is_flag=True,
  1507. help="Whether to queue a scheduling job instead of computing directly. "
  1508. "To process the job, run a worker (on any computer, but configured to the same databases) to process the 'scheduling' queue. Defaults to False.",
  1509. )
  1510. def add_schedule_process(
  1511. power_sensor: Sensor,
  1512. consumption_price_sensor: Sensor,
  1513. start: datetime,
  1514. duration: timedelta,
  1515. process_duration: timedelta,
  1516. process_type: str,
  1517. process_power: ur.Quantity,
  1518. forbid: list | None = None,
  1519. as_job: bool = False,
  1520. ):
  1521. """Create a new schedule for a process asset.
  1522. Current limitations:
  1523. - Only supports consumption blocks.
  1524. - Not taking into account grid constraints or other processes.
  1525. """
  1526. if forbid is None:
  1527. forbid = []
  1528. # Parse input and required sensor attributes
  1529. if not power_sensor.measures_power:
  1530. click.secho(
  1531. f"Sensor with ID {power_sensor.id} is not a power sensor.",
  1532. **MsgStyle.ERROR,
  1533. )
  1534. raise click.Abort()
  1535. end = start + duration
  1536. process_power = convert_units(process_power.magnitude, process_power.units, "MW") # type: ignore
  1537. scheduling_kwargs = dict(
  1538. start=start,
  1539. end=end,
  1540. belief_time=server_now(),
  1541. resolution=power_sensor.event_resolution,
  1542. flex_model={
  1543. "duration": pd.Timedelta(process_duration).isoformat(),
  1544. "process-type": process_type,
  1545. "power": process_power,
  1546. "time-restrictions": [TimeIntervalSchema().dump(f) for f in forbid],
  1547. },
  1548. )
  1549. if consumption_price_sensor is not None:
  1550. scheduling_kwargs["flex_context"] = {
  1551. "consumption-price": {"sensor": consumption_price_sensor.id},
  1552. }
  1553. if as_job:
  1554. job = create_scheduling_job(asset_or_sensor=power_sensor, **scheduling_kwargs)
  1555. if job:
  1556. click.secho(
  1557. f"New scheduling job {job.id} has been added to the queue.",
  1558. **MsgStyle.SUCCESS,
  1559. )
  1560. else:
  1561. success = make_schedule(
  1562. asset_or_sensor=get_asset_or_sensor_ref(power_sensor),
  1563. **scheduling_kwargs,
  1564. )
  1565. if success:
  1566. click.secho("New schedule is stored.", **MsgStyle.SUCCESS)
  1567. @fm_add_data.command("report")
  1568. @with_appcontext
  1569. @click.option(
  1570. "--config",
  1571. "config_file",
  1572. required=False,
  1573. type=click.File("r"),
  1574. help="Path to the JSON or YAML file with the configuration of the reporter.",
  1575. )
  1576. @click.option(
  1577. "--source",
  1578. "source",
  1579. required=False,
  1580. type=DataSourceIdField(),
  1581. help="DataSource ID of the `Reporter`.",
  1582. )
  1583. @click.option(
  1584. "--parameters",
  1585. "parameters_file",
  1586. required=False,
  1587. type=click.File("r"),
  1588. help="Path to the JSON or YAML file with the report parameters (passed to the compute step).",
  1589. )
  1590. @click.option(
  1591. "--reporter",
  1592. "reporter_class",
  1593. default="PandasReporter",
  1594. type=click.STRING,
  1595. help="Reporter class registered in flexmeasures.data.models.reporting or in an available flexmeasures plugin."
  1596. " Use the command `flexmeasures show reporters` to list all the available reporters.",
  1597. )
  1598. @click.option(
  1599. "--start",
  1600. "start",
  1601. type=AwareDateTimeField(format="iso"),
  1602. required=False,
  1603. help="Report start time. `--start-offset` can be used instead. Follow up with a timezone-aware datetime in ISO 6801 format.",
  1604. )
  1605. @click.option(
  1606. "--start-offset",
  1607. "start_offset",
  1608. type=str,
  1609. required=False,
  1610. help="Report start offset time from now. Use multiple Pandas offset strings separated by commas, e.g: -3D,DB,1W. Use DB or HB to offset to the begin of the day or hour, respectively.",
  1611. )
  1612. @click.option(
  1613. "--end-offset",
  1614. "end_offset",
  1615. type=str,
  1616. required=False,
  1617. help="Report end offset time from now. Use multiple Pandas offset strings separated by commas, e.g: -3D,DB,1W. Use DB or HB to offset to the begin of the day or hour, respectively.",
  1618. )
  1619. @click.option(
  1620. "--end",
  1621. "end",
  1622. type=AwareDateTimeField(format="iso"),
  1623. required=False,
  1624. help="Report end time. `--end-offset` can be used instead. Follow up with a timezone-aware datetime in ISO 6801 format.",
  1625. )
  1626. @click.option(
  1627. "--resolution",
  1628. "resolution",
  1629. type=DurationField(format="iso"),
  1630. required=False,
  1631. help="Time resolution of the input time series to employ for the calculations. Follow up with a ISO 8601 duration string",
  1632. )
  1633. @click.option(
  1634. "--output-file",
  1635. "output_file_pattern",
  1636. required=False,
  1637. type=click.Path(),
  1638. help="Format of the output file. Use dollar sign ($) to interpolate values among the following ones:"
  1639. " now (current time), name (name of the output), sensor_id (id of the sensor), column (column of the output)."
  1640. " Example: 'result_file_$name_$now.csv'. "
  1641. "Use the `.csv` suffix to save the results as Comma Separated Values and `.xlsx` to export them as Excel sheets.",
  1642. )
  1643. @click.option(
  1644. "--timezone",
  1645. "timezone",
  1646. required=False,
  1647. help="Timezone as string, e.g. 'UTC' or 'Europe/Amsterdam' (defaults to the timezone of the sensor used to save the report)."
  1648. "The timezone of the first output sensor (specified in the parameters) is taken as a default.",
  1649. )
  1650. @click.option(
  1651. "--dry-run",
  1652. "dry_run",
  1653. is_flag=True,
  1654. help="Add this flag to avoid saving the results to the database.",
  1655. )
  1656. @click.option(
  1657. "--edit-config",
  1658. "edit_config",
  1659. is_flag=True,
  1660. help="Add this flag to edit the configuration of the Reporter in your default text editor (e.g. nano).",
  1661. )
  1662. @click.option(
  1663. "--edit-parameters",
  1664. "edit_parameters",
  1665. is_flag=True,
  1666. help="Add this flag to edit the parameters passed to the Reporter in your default text editor (e.g. nano).",
  1667. )
  1668. @click.option(
  1669. "--save-config",
  1670. "save_config",
  1671. is_flag=True,
  1672. help="Add this flag to save the `config` in the attributes of the DataSource for future reference.",
  1673. )
  1674. def add_report( # noqa: C901
  1675. reporter_class: str,
  1676. source: DataSource | None = None,
  1677. config_file: TextIOBase | None = None,
  1678. parameters_file: TextIOBase | None = None,
  1679. start: datetime | None = None,
  1680. end: datetime | None = None,
  1681. start_offset: str | None = None,
  1682. end_offset: str | None = None,
  1683. resolution: timedelta | None = None,
  1684. output_file_pattern: Path | None = None,
  1685. dry_run: bool = False,
  1686. edit_config: bool = False,
  1687. edit_parameters: bool = False,
  1688. save_config: bool = False,
  1689. timezone: str | None = None,
  1690. ):
  1691. """
  1692. Create a new report using the Reporter class and save the results
  1693. to the database or export them as CSV or Excel file.
  1694. """
  1695. config = dict()
  1696. if config_file:
  1697. config = yaml.safe_load(config_file)
  1698. if edit_config:
  1699. config = launch_editor("/tmp/config.yml")
  1700. parameters = dict()
  1701. if parameters_file:
  1702. parameters = yaml.safe_load(parameters_file)
  1703. if edit_parameters:
  1704. parameters = launch_editor("/tmp/parameters.yml")
  1705. # check if sensor is not provided in the `parameters` description
  1706. if "output" not in parameters or len(parameters["output"]) == 0:
  1707. click.secho(
  1708. "At least one output sensor needs to be specified in the parameters description.",
  1709. **MsgStyle.ERROR,
  1710. )
  1711. raise click.Abort()
  1712. output = [Output().load(o) for o in parameters["output"]]
  1713. # compute now in the timezone local to the output sensor
  1714. if timezone is not None:
  1715. check_timezone(timezone)
  1716. now = pytz.timezone(
  1717. zone=timezone if timezone is not None else output[0]["sensor"].timezone
  1718. ).localize(datetime.now())
  1719. # apply offsets, if provided
  1720. if start_offset is not None:
  1721. if start is None:
  1722. start = now
  1723. start = apply_offset_chain(start, start_offset)
  1724. if end_offset is not None:
  1725. if end is None:
  1726. end = now
  1727. end = apply_offset_chain(end, end_offset)
  1728. # the case of not getting --start or --start-offset
  1729. if start is None:
  1730. click.secho(
  1731. "Either --start or --start-offset should be provided."
  1732. " Trying to use the latest datapoint of the report sensor as the start time...",
  1733. **MsgStyle.WARN,
  1734. )
  1735. # todo: get the oldest last_value among all the sensors
  1736. last_value_datetime = db.session.execute(
  1737. select(func.max(TimedBelief.event_start))
  1738. .select_from(TimedBelief)
  1739. .filter_by(sensor_id=output[0]["sensor"].id)
  1740. ).scalar_one_or_none()
  1741. # If there's data saved to the reporter sensors
  1742. if last_value_datetime is not None:
  1743. start = last_value_datetime
  1744. else:
  1745. click.secho(
  1746. "Could not find any data for the output sensors provided. Such data is needed to compute"
  1747. " a sensible default start for the report, so setting a start explicitly would resolve this issue.",
  1748. **MsgStyle.ERROR,
  1749. )
  1750. raise click.Abort()
  1751. # the case of not getting --end or --end-offset
  1752. if end is None:
  1753. click.secho(
  1754. "Either --end or --end-offset should be provided."
  1755. " Trying to use the current time as the end...",
  1756. **MsgStyle.WARN,
  1757. )
  1758. end = now
  1759. click.echo(f"Report scope:\n\tstart: {start}\n\tend: {end}")
  1760. if end < start:
  1761. click.secho(
  1762. "Invalid report period (end must not precede start).",
  1763. **MsgStyle.ERROR,
  1764. )
  1765. raise click.Abort()
  1766. if source is None:
  1767. click.echo(
  1768. f"Looking for the Reporter {reporter_class} among all the registered reporters...",
  1769. )
  1770. # get reporter class
  1771. ReporterClass: Type[Reporter] = app.data_generators.get("reporter").get(
  1772. reporter_class
  1773. )
  1774. # check if it exists
  1775. if ReporterClass is None:
  1776. click.secho(
  1777. f"Reporter class `{reporter_class}` not available.",
  1778. **MsgStyle.ERROR,
  1779. )
  1780. raise click.Abort()
  1781. click.secho(f"Reporter {reporter_class} found.", **MsgStyle.SUCCESS)
  1782. # initialize reporter class with the reporter sensor and reporter config
  1783. reporter: Reporter = ReporterClass(config=config, save_config=save_config)
  1784. else:
  1785. try:
  1786. reporter: Reporter = source.data_generator # type: ignore
  1787. if not isinstance(reporter, Reporter):
  1788. raise NotImplementedError(
  1789. f"DataGenerator `{reporter}` is not of the type `Reporter`"
  1790. )
  1791. click.secho(
  1792. f"Reporter `{reporter.__class__.__name__}` fetched successfully from the database.",
  1793. **MsgStyle.SUCCESS,
  1794. )
  1795. except NotImplementedError:
  1796. click.secho(
  1797. f"Error! DataSource `{source}` not storing a valid Reporter.",
  1798. **MsgStyle.ERROR,
  1799. )
  1800. reporter._save_config = save_config
  1801. if ("start" not in parameters) and (start is not None):
  1802. parameters["start"] = start.isoformat()
  1803. if ("end" not in parameters) and (end is not None):
  1804. parameters["end"] = end.isoformat()
  1805. if ("resolution" not in parameters) and (resolution is not None):
  1806. parameters["resolution"] = pd.Timedelta(resolution).isoformat()
  1807. click.echo("Report computation is running...")
  1808. # compute the report
  1809. results: BeliefsDataFrame = reporter.compute(parameters=parameters)
  1810. for result in results:
  1811. data = result["data"]
  1812. sensor = result["sensor"]
  1813. if not data.empty:
  1814. click.secho(
  1815. f"Report computation done for sensor `{sensor}`.", **MsgStyle.SUCCESS
  1816. )
  1817. else:
  1818. click.secho(
  1819. f"Report computation done for sensor `{sensor}`, but the report is empty.",
  1820. **MsgStyle.WARN,
  1821. )
  1822. # save the report if it's not running in dry mode
  1823. if not dry_run:
  1824. click.echo(f"Saving report for sensor `{sensor}` to the database...")
  1825. save_to_db(data.dropna())
  1826. db.session.commit()
  1827. click.secho(
  1828. f"Success. The report for sensor `{sensor}` has been saved to the database.",
  1829. **MsgStyle.SUCCESS,
  1830. )
  1831. else:
  1832. click.echo(
  1833. f"Not saving report for sensor `{sensor}` to the database (because of --dry-run), but this is what I computed:\n{data}"
  1834. )
  1835. # if an output file path is provided, save the data
  1836. if output_file_pattern:
  1837. suffix = (
  1838. str(output_file_pattern).split(".")[-1]
  1839. if "." in str(output_file_pattern)
  1840. else ""
  1841. )
  1842. template = Template(str(output_file_pattern))
  1843. filename = template.safe_substitute(
  1844. sensor_id=result["sensor"].id,
  1845. name=result.get("name", ""),
  1846. column=result.get("column", ""),
  1847. reporter_class=reporter_class,
  1848. now=now.strftime("%Y_%m_%dT%H%M%S"),
  1849. )
  1850. if suffix == "xlsx": # save to EXCEL
  1851. data.to_excel(filename)
  1852. click.secho(
  1853. f"Success. The report for sensor `{sensor}` has been exported as EXCEL to the file `{filename}`",
  1854. **MsgStyle.SUCCESS,
  1855. )
  1856. elif suffix == "csv": # save to CSV
  1857. data.to_csv(filename)
  1858. click.secho(
  1859. f"Success. The report for sensor `{sensor}` has been exported as CSV to the file `{filename}`",
  1860. **MsgStyle.SUCCESS,
  1861. )
  1862. else: # default output format: CSV.
  1863. click.secho(
  1864. f"File suffix not provided. Exporting results for sensor `{sensor}` as CSV to file {filename}",
  1865. **MsgStyle.WARN,
  1866. )
  1867. data.to_csv(filename)
  1868. else:
  1869. click.secho(
  1870. "Success.",
  1871. **MsgStyle.SUCCESS,
  1872. )
  1873. def launch_editor(filename: str) -> dict:
  1874. """Launch editor to create/edit a json object"""
  1875. click.edit("{\n}", filename=filename)
  1876. with open(filename, "r") as f:
  1877. content = yaml.safe_load(f)
  1878. if content is None:
  1879. return dict()
  1880. return content
  1881. @fm_add_data.command("toy-account")
  1882. @with_appcontext
  1883. @click.option(
  1884. "--kind",
  1885. default="battery",
  1886. type=click.Choice(["battery", "process", "reporter"]),
  1887. help="What kind of toy account. Defaults to a battery.",
  1888. )
  1889. @click.option("--name", type=str, default="Toy Account", help="Name of the account")
  1890. def add_toy_account(kind: str, name: str):
  1891. """
  1892. Create a toy account, for tutorials and trying things.
  1893. """
  1894. asset_types = add_default_asset_types(db=db)
  1895. location = (52.374, 4.88969) # Amsterdam
  1896. # make an account (if not exist)
  1897. account = db.session.execute(
  1898. select(Account).filter_by(name=name)
  1899. ).scalar_one_or_none()
  1900. if account:
  1901. click.secho(
  1902. f"Account '{account}' already exists. Skipping account creation. Use `flexmeasures delete account --id {account.id}` if you need to remove it.",
  1903. **MsgStyle.WARN,
  1904. )
  1905. # make an account user (account-admin?)
  1906. email = "toy-user@flexmeasures.io"
  1907. user = db.session.execute(select(User).filter_by(email=email)).scalar_one_or_none()
  1908. if user is not None:
  1909. click.secho(
  1910. f"User with email {email} already exists in account {user.account.name}.",
  1911. **MsgStyle.WARN,
  1912. )
  1913. else:
  1914. user = create_user(
  1915. email=email,
  1916. check_email_deliverability=False,
  1917. password="toy-password",
  1918. user_roles=["account-admin"],
  1919. account_name=name,
  1920. )
  1921. click.secho(
  1922. f"Toy account {name} with user {user.email} created successfully. You might want to run `flexmeasures show account --id {user.account.id}`",
  1923. **MsgStyle.SUCCESS,
  1924. )
  1925. db.session.commit()
  1926. # add public day-ahead market (as sensor of transmission zone asset)
  1927. nl_zone = add_transmission_zone_asset("NL", db=db)
  1928. day_ahead_sensor = get_or_create_model(
  1929. Sensor,
  1930. name="day-ahead prices",
  1931. generic_asset=nl_zone,
  1932. unit="EUR/MWh",
  1933. timezone="Europe/Amsterdam",
  1934. event_resolution=timedelta(minutes=60),
  1935. knowledge_horizon=(
  1936. x_days_ago_at_y_oclock,
  1937. {"x": 1, "y": 12, "z": "Europe/Paris"},
  1938. ),
  1939. )
  1940. db.session.commit()
  1941. click.secho(
  1942. f"The sensor recording day-ahead prices is {day_ahead_sensor} (ID: {day_ahead_sensor.id}).",
  1943. **MsgStyle.SUCCESS,
  1944. )
  1945. account_id = user.account_id
  1946. def create_asset_with_one_sensor(
  1947. asset_name: str,
  1948. asset_type: str,
  1949. sensor_name: str,
  1950. unit: str = "MW",
  1951. parent_asset_id: int | None = None,
  1952. flex_context: dict | None = None,
  1953. **asset_attributes,
  1954. ):
  1955. asset_kwargs: Dict[str, Any] = {}
  1956. if parent_asset_id is not None:
  1957. asset_kwargs["parent_asset_id"] = parent_asset_id
  1958. if flex_context is not None:
  1959. asset_kwargs["flex_context"] = flex_context
  1960. asset = get_or_create_model(
  1961. GenericAsset,
  1962. name=asset_name,
  1963. generic_asset_type=asset_types[asset_type],
  1964. owner=db.session.get(Account, account_id),
  1965. latitude=location[0],
  1966. longitude=location[1],
  1967. **asset_kwargs,
  1968. )
  1969. if asset.flex_context is None:
  1970. asset.flex_context = {}
  1971. if len(asset_attributes) > 0:
  1972. asset.attributes = asset_attributes
  1973. sensor_specs = dict(
  1974. generic_asset=asset,
  1975. unit=unit,
  1976. timezone="Europe/Amsterdam",
  1977. event_resolution=timedelta(minutes=15),
  1978. )
  1979. sensor = get_or_create_model(
  1980. Sensor,
  1981. name=sensor_name,
  1982. **sensor_specs,
  1983. )
  1984. return sensor
  1985. # create building asset
  1986. building_asset = get_or_create_model(
  1987. GenericAsset,
  1988. name="toy-building",
  1989. generic_asset_type=asset_types["building"],
  1990. owner=db.session.get(Account, account_id),
  1991. latitude=location[0],
  1992. longitude=location[1],
  1993. )
  1994. db.session.flush()
  1995. if kind == "battery":
  1996. # create battery
  1997. discharging_sensor = create_asset_with_one_sensor(
  1998. "toy-battery",
  1999. "battery",
  2000. "discharging",
  2001. parent_asset_id=building_asset.id,
  2002. flex_context={"consumption-price": {"sensor": day_ahead_sensor.id}},
  2003. capacity_in_mw="500 kVA",
  2004. min_soc_in_mwh=0.05,
  2005. max_soc_in_mwh=0.45,
  2006. )
  2007. # create solar
  2008. production_sensor = create_asset_with_one_sensor(
  2009. "toy-solar", "solar", "production", parent_asset_id=building_asset.id
  2010. )
  2011. # add day-ahead price sensor and PV production sensor to show on the battery's asset page
  2012. db.session.flush()
  2013. battery = discharging_sensor.generic_asset
  2014. battery.sensors_to_show = [
  2015. {"title": "Prices", "sensor": day_ahead_sensor.id},
  2016. {
  2017. "title": "Power flows",
  2018. "sensors": [production_sensor.id, discharging_sensor.id],
  2019. },
  2020. ]
  2021. db.session.commit()
  2022. click.secho(
  2023. f"The sensor recording battery discharging is {discharging_sensor} (ID: {discharging_sensor.id}).",
  2024. **MsgStyle.SUCCESS,
  2025. )
  2026. click.secho(
  2027. f"The sensor recording solar forecasts is {production_sensor} (ID: {production_sensor.id}).",
  2028. **MsgStyle.SUCCESS,
  2029. )
  2030. elif kind == "process":
  2031. inflexible_power = create_asset_with_one_sensor(
  2032. "toy-process",
  2033. "process",
  2034. "Power (Inflexible)",
  2035. )
  2036. breakable_power = create_asset_with_one_sensor(
  2037. "toy-process",
  2038. "process",
  2039. "Power (Breakable)",
  2040. )
  2041. shiftable_power = create_asset_with_one_sensor(
  2042. "toy-process",
  2043. "process",
  2044. "Power (Shiftable)",
  2045. )
  2046. db.session.flush()
  2047. process = shiftable_power.generic_asset
  2048. process.sensors_to_show = [
  2049. {"title": "Prices", "sensor": day_ahead_sensor.id},
  2050. {"title": "Inflexible", "sensor": inflexible_power.id},
  2051. {"title": "Breakable", "sensor": breakable_power.id},
  2052. {"title": "Shiftable", "sensor": shiftable_power.id},
  2053. ]
  2054. db.session.commit()
  2055. click.secho(
  2056. f"The sensor recording the power of the inflexible load is {inflexible_power} (ID: {inflexible_power.id}).",
  2057. **MsgStyle.SUCCESS,
  2058. )
  2059. click.secho(
  2060. f"The sensor recording the power of the breakable load is {breakable_power} (ID: {breakable_power.id}).",
  2061. **MsgStyle.SUCCESS,
  2062. )
  2063. click.secho(
  2064. f"The sensor recording the power of the shiftable load is {shiftable_power} (ID: {shiftable_power.id}).",
  2065. **MsgStyle.SUCCESS,
  2066. )
  2067. elif kind == "reporter":
  2068. # Part A) of tutorial IV
  2069. grid_connection_capacity = get_or_create_model(
  2070. Sensor,
  2071. name="grid connection capacity",
  2072. generic_asset=building_asset,
  2073. timezone="Europe/Amsterdam",
  2074. event_resolution="P1Y",
  2075. unit="MW",
  2076. )
  2077. db.session.commit()
  2078. click.secho(
  2079. f"The sensor storing the grid connection capacity of the building is {grid_connection_capacity} (ID: {grid_connection_capacity.id}).",
  2080. **MsgStyle.SUCCESS,
  2081. )
  2082. tz = pytz.timezone(app.config.get("FLEXMEASURES_TIMEZONE", "Europe/Amsterdam"))
  2083. current_year = datetime.now().year
  2084. start_year = datetime(current_year, 1, 1)
  2085. belief = TimedBelief(
  2086. event_start=tz.localize(start_year),
  2087. belief_time=tz.localize(datetime.now()),
  2088. event_value=0.5,
  2089. source=db.session.get(DataSource, 1),
  2090. sensor=grid_connection_capacity,
  2091. )
  2092. db.session.add(belief)
  2093. db.session.commit()
  2094. headroom = create_asset_with_one_sensor(
  2095. "toy-battery", "battery", "headroom", parent_asset_id=building_asset.id
  2096. )
  2097. db.session.commit()
  2098. click.secho(
  2099. f"The sensor storing the headroom is {headroom} (ID: {headroom.id}).",
  2100. **MsgStyle.SUCCESS,
  2101. )
  2102. for name in ["Inflexible", "Breakable", "Shiftable"]:
  2103. loss_sensor = create_asset_with_one_sensor(
  2104. "toy-process", "process", f"costs ({name})", unit="EUR"
  2105. )
  2106. db.session.commit()
  2107. click.secho(
  2108. f"The sensor storing the loss is {loss_sensor} (ID: {loss_sensor.id}).",
  2109. **MsgStyle.SUCCESS,
  2110. )
  2111. reporter = ProfitOrLossReporter(
  2112. consumption_price_sensor=day_ahead_sensor, loss_is_positive=True
  2113. )
  2114. ds = reporter.data_source
  2115. db.session.commit()
  2116. click.secho(
  2117. f"Reporter `ProfitOrLossReporter` saved with the day ahead price sensor in the `DataSource` (id={ds.id})",
  2118. **MsgStyle.SUCCESS,
  2119. )
  2120. app.cli.add_command(fm_add_data)
  2121. def check_timezone(timezone):
  2122. try:
  2123. pytz.timezone(timezone)
  2124. except pytz.UnknownTimeZoneError:
  2125. click.secho("Timezone %s is unknown!" % timezone, **MsgStyle.ERROR)
  2126. raise click.Abort()
  2127. def check_errors(errors: dict[str, list[str]]):
  2128. if errors:
  2129. click.secho(
  2130. f"Please correct the following errors:\n{errors}.\n Use the --help flag to learn more.",
  2131. **MsgStyle.ERROR,
  2132. )
  2133. raise click.Abort()
  2134. def parse_source(source):
  2135. if source.isdigit():
  2136. _source = get_source_or_none(int(source))
  2137. if not _source:
  2138. click.secho(f"Failed to find source {source}.", **MsgStyle.ERROR)
  2139. raise click.Abort()
  2140. else:
  2141. _source = get_or_create_source(source, source_type="CLI script")
  2142. return _source