sensors.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032
  1. from __future__ import annotations
  2. import isodate
  3. from datetime import datetime, timedelta
  4. from flexmeasures.data.services.sensors import (
  5. serialize_sensor_status_data,
  6. )
  7. from werkzeug.exceptions import Unauthorized
  8. from flask import current_app, url_for
  9. from flask_classful import FlaskView, route
  10. from flask_json import as_json
  11. from flask_security import auth_required, current_user
  12. from marshmallow import fields, ValidationError
  13. import marshmallow.validate as validate
  14. from rq.job import Job, NoSuchJobError
  15. from timely_beliefs import BeliefsDataFrame
  16. from webargs.flaskparser import use_args, use_kwargs
  17. from sqlalchemy import delete, select, or_
  18. from flexmeasures.api.common.responses import (
  19. request_processed,
  20. unrecognized_event,
  21. unknown_schedule,
  22. invalid_flex_config,
  23. fallback_schedule_redirect,
  24. )
  25. from flexmeasures.api.common.utils.validators import (
  26. optional_duration_accepted,
  27. )
  28. from flexmeasures.api.common.schemas.sensor_data import (
  29. GetSensorDataSchema,
  30. PostSensorDataSchema,
  31. )
  32. from flexmeasures.api.common.schemas.users import AccountIdField
  33. from flexmeasures.api.common.utils.api_utils import save_and_enqueue
  34. from flexmeasures.auth.policy import check_access
  35. from flexmeasures.auth.decorators import permission_required_for_context
  36. from flexmeasures.data import db
  37. from flexmeasures.data.models.audit_log import AssetAuditLog
  38. from flexmeasures.data.models.user import Account
  39. from flexmeasures.data.models.generic_assets import GenericAsset
  40. from flexmeasures.data.models.time_series import Sensor, TimedBelief
  41. from flexmeasures.data.queries.utils import simplify_index
  42. from flexmeasures.data.schemas.sensors import SensorSchema, SensorIdField
  43. from flexmeasures.data.schemas import AssetIdField
  44. from flexmeasures.api.common.schemas.search import SearchFilterField
  45. from flexmeasures.api.common.schemas.sensors import UnitField
  46. from flexmeasures.data.schemas.times import AwareDateTimeField, PlanningDurationField
  47. from flexmeasures.data.services.sensors import get_sensor_stats
  48. from flexmeasures.data.services.scheduling import (
  49. create_scheduling_job,
  50. get_data_source_for_job,
  51. )
  52. from flexmeasures.utils.time_utils import duration_isoformat
  53. # Instantiate schemas outside of endpoint logic to minimize response time
  54. get_sensor_schema = GetSensorDataSchema()
  55. post_sensor_schema = PostSensorDataSchema()
  56. sensors_schema = SensorSchema(many=True)
  57. sensor_schema = SensorSchema()
  58. partial_sensor_schema = SensorSchema(partial=True, exclude=["generic_asset_id"])
  59. class SensorAPI(FlaskView):
  60. route_base = "/sensors"
  61. trailing_slash = False
  62. decorators = [auth_required()]
  63. @route("", methods=["GET"])
  64. @use_kwargs(
  65. {
  66. "account": AccountIdField(data_key="account_id", required=False),
  67. "asset": AssetIdField(data_key="asset_id", required=False),
  68. "include_consultancy_clients": fields.Boolean(
  69. required=False, load_default=False
  70. ),
  71. "include_public_assets": fields.Boolean(required=False, load_default=False),
  72. "page": fields.Int(
  73. required=False, validate=validate.Range(min=1), load_default=None
  74. ),
  75. "per_page": fields.Int(
  76. required=False, validate=validate.Range(min=1), load_default=10
  77. ),
  78. "filter": SearchFilterField(required=False, load_default=None),
  79. "unit": UnitField(required=False, load_default=None),
  80. },
  81. location="query",
  82. )
  83. @as_json
  84. def index(
  85. self,
  86. account: Account | None = None,
  87. asset: GenericAsset | None = None,
  88. include_consultancy_clients: bool = False,
  89. include_public_assets: bool = False,
  90. page: int | None = None,
  91. per_page: int | None = None,
  92. filter: list[str] | None = None,
  93. unit: str | None = None,
  94. ):
  95. """API endpoint to list all sensors of an account.
  96. .. :quickref: Sensor; Get list of sensors
  97. This endpoint returns all accessible sensors.
  98. By default, "accessible sensors" means all sensors in the same account as the current user (if they have read permission to the account).
  99. You can also specify an `account` (an ID parameter), if the user has read access to that account. In this case, all assets under the
  100. specified account will be retrieved, and the sensors associated with these assets will be returned.
  101. Alternatively, you can filter by asset hierarchy by providing the `asset` parameter (ID). When this is set, all sensors on the specified
  102. asset and its sub-assets are retrieved, provided the user has read access to the asset.
  103. NOTE: You can't set both account and asset at the same time, you can only have one set. The only exception is if the asset being specified is
  104. part of the account that was set, then we allow to see sensors under that asset but then ignore the account (account = None).
  105. Finally, you can use the `include_consultancy_clients` parameter to include sensors from accounts for which the current user account is a consultant.
  106. This is only possible if the user has the role of a consultant.
  107. Only admins can use this endpoint to fetch sensors from a different account (by using the `account_id` query parameter).
  108. The `filter` parameter allows you to search for sensors by name or account name.
  109. The `unit` parameter allows you to filter by unit.
  110. For the pagination of the sensor list, you can use the `page` and `per_page` query parameters, the `page` parameter is used to trigger
  111. pagination, and the `per_page` parameter is used to specify the number of records per page. The default value for `page` is 1 and for `per_page` is 10.
  112. **Example response**
  113. An example of one sensor being returned:
  114. .. sourcecode:: json
  115. {
  116. "data" : [
  117. {
  118. "entity_address": "ea1.2021-01.io.flexmeasures.company:fm1.42",
  119. "event_resolution": PT15M,
  120. "generic_asset_id": 1,
  121. "name": "Gas demand",
  122. "timezone": "Europe/Amsterdam",
  123. "unit": "m\u00b3/h"
  124. "id": 2
  125. }
  126. ],
  127. "num-records" : 1,
  128. "filtered-records" : 1
  129. }
  130. If no pagination is requested, the response only consists of the list under the "data" key.
  131. :reqheader Authorization: The authentication token
  132. :reqheader Content-Type: application/json
  133. :resheader Content-Type: application/json
  134. :status 200: PROCESSED
  135. :status 400: INVALID_REQUEST
  136. :status 401: UNAUTHORIZED
  137. :status 403: INVALID_SENDER
  138. :status 422: UNPROCESSABLE_ENTITY
  139. """
  140. if account is None and asset is None:
  141. if current_user.is_anonymous:
  142. raise Unauthorized
  143. account = current_user.account
  144. if account is not None and asset is not None:
  145. if asset.account_id != account.id:
  146. return {
  147. "message": "Please provide either an account or an asset ID, not both"
  148. }, 422
  149. else:
  150. account = None
  151. if asset is not None:
  152. check_access(asset, "read")
  153. asset_tree = (
  154. db.session.query(GenericAsset.id, GenericAsset.parent_asset_id)
  155. .filter(GenericAsset.id == asset.id)
  156. .cte(name="asset_tree", recursive=True)
  157. )
  158. recursive_part = db.session.query(
  159. GenericAsset.id, GenericAsset.parent_asset_id
  160. ).join(asset_tree, GenericAsset.parent_asset_id == asset_tree.c.id)
  161. asset_tree = asset_tree.union(recursive_part)
  162. child_assets = db.session.query(asset_tree).all()
  163. filter_statement = GenericAsset.id.in_(
  164. [asset.id] + [a.id for a in child_assets]
  165. )
  166. elif account is not None:
  167. check_access(account, "read")
  168. account_ids: list = [account.id]
  169. if include_consultancy_clients:
  170. if current_user.has_role("consultant"):
  171. consultancy_accounts = (
  172. db.session.query(Account)
  173. .filter(Account.consultancy_account_id == account.id)
  174. .all()
  175. )
  176. account_ids.extend([acc.id for acc in consultancy_accounts])
  177. filter_statement = GenericAsset.account_id.in_(account_ids)
  178. else:
  179. filter_statement = None
  180. if include_public_assets:
  181. filter_statement = or_(
  182. filter_statement,
  183. GenericAsset.account_id.is_(None),
  184. )
  185. sensor_query = (
  186. select(Sensor)
  187. .join(GenericAsset, Sensor.generic_asset_id == GenericAsset.id)
  188. .outerjoin(Account, GenericAsset.owner)
  189. .filter(filter_statement)
  190. )
  191. if filter is not None:
  192. sensor_query = sensor_query.filter(
  193. or_(
  194. *(
  195. or_(
  196. Sensor.name.ilike(f"%{term}%"),
  197. Account.name.ilike(f"%{term}%"),
  198. GenericAsset.name.ilike(f"%{term}%"),
  199. )
  200. for term in filter
  201. )
  202. )
  203. )
  204. if unit:
  205. sensor_query = sensor_query.filter(Sensor.unit == unit)
  206. sensors = (
  207. db.session.scalars(sensor_query).all()
  208. if page is None
  209. else db.paginate(sensor_query, per_page=per_page, page=page).items
  210. )
  211. sensors = [sensor for sensor in sensors if check_access(sensor, "read") is None]
  212. sensors_response = sensors_schema.dump(sensors)
  213. # Return appropriate response for paginated or non-paginated data
  214. if page is None:
  215. return sensors_response, 200
  216. else:
  217. num_records = len(db.session.execute(sensor_query).scalars().all())
  218. select_pagination = db.paginate(sensor_query, per_page=per_page, page=page)
  219. response = {
  220. "data": sensors_response,
  221. "num-records": num_records,
  222. "filtered-records": select_pagination.total,
  223. }
  224. return response, 200
  225. @route("/data", methods=["POST"])
  226. @use_args(
  227. post_sensor_schema,
  228. location="json",
  229. )
  230. @permission_required_for_context(
  231. "create-children",
  232. ctx_arg_pos=1,
  233. ctx_loader=lambda bdf: bdf.sensor,
  234. pass_ctx_to_loader=True,
  235. )
  236. def post_data(self, bdf: BeliefsDataFrame):
  237. """
  238. Post sensor data to FlexMeasures.
  239. .. :quickref: Data; Upload sensor data
  240. **Example request**
  241. .. code-block:: json
  242. {
  243. "sensor": "ea1.2021-01.io.flexmeasures:fm1.1",
  244. "values": [-11.28, -11.28, -11.28, -11.28],
  245. "start": "2021-06-07T00:00:00+02:00",
  246. "duration": "PT1H",
  247. "unit": "m³/h"
  248. }
  249. The above request posts four values for a duration of one hour, where the first
  250. event start is at the given start time, and subsequent events start in 15 minute intervals throughout the one hour duration.
  251. The sensor is the one with ID=1.
  252. The unit has to be convertible to the sensor's unit.
  253. The resolution of the data has to match the sensor's required resolution, but
  254. FlexMeasures will attempt to upsample lower resolutions.
  255. The list of values may include null values.
  256. :reqheader Authorization: The authentication token
  257. :reqheader Content-Type: application/json
  258. :resheader Content-Type: application/json
  259. :status 200: PROCESSED
  260. :status 400: INVALID_REQUEST
  261. :status 401: UNAUTHORIZED
  262. :status 403: INVALID_SENDER
  263. :status 422: UNPROCESSABLE_ENTITY
  264. """
  265. response, code = save_and_enqueue(bdf)
  266. return response, code
  267. @route("/data", methods=["GET"])
  268. @use_args(
  269. get_sensor_schema,
  270. location="query",
  271. )
  272. @permission_required_for_context("read", ctx_arg_pos=1, ctx_arg_name="sensor")
  273. def get_data(self, sensor_data_description: dict):
  274. """Get sensor data from FlexMeasures.
  275. .. :quickref: Data; Download sensor data
  276. **Example request**
  277. .. code-block:: json
  278. {
  279. "sensor": "ea1.2021-01.io.flexmeasures:fm1.1",
  280. "start": "2021-06-07T00:00:00+02:00",
  281. "duration": "PT1H",
  282. "resolution": "PT15M",
  283. "unit": "m³/h"
  284. }
  285. The unit has to be convertible from the sensor's unit.
  286. **Optional fields**
  287. - "resolution" (see :ref:`frequency_and_resolution`)
  288. - "horizon" (see :ref:`beliefs`)
  289. - "prior" (see :ref:`beliefs`)
  290. - "source" (see :ref:`sources`)
  291. :reqheader Authorization: The authentication token
  292. :reqheader Content-Type: application/json
  293. :resheader Content-Type: application/json
  294. :status 200: PROCESSED
  295. :status 400: INVALID_REQUEST
  296. :status 401: UNAUTHORIZED
  297. :status 403: INVALID_SENDER
  298. :status 422: UNPROCESSABLE_ENTITY
  299. """
  300. response = GetSensorDataSchema.load_data_and_make_response(
  301. sensor_data_description
  302. )
  303. d, s = request_processed()
  304. return dict(**response, **d), s
  305. @route("/<id>/schedules/trigger", methods=["POST"])
  306. @use_kwargs(
  307. {"sensor": SensorIdField(data_key="id")},
  308. location="path",
  309. )
  310. @use_kwargs(
  311. {
  312. "start_of_schedule": AwareDateTimeField(
  313. data_key="start", format="iso", required=True
  314. ),
  315. "belief_time": AwareDateTimeField(format="iso", data_key="prior"),
  316. "duration": PlanningDurationField(
  317. load_default=PlanningDurationField.load_default
  318. ),
  319. "flex_model": fields.Dict(data_key="flex-model"),
  320. "flex_context": fields.Dict(required=False, data_key="flex-context"),
  321. "force_new_job_creation": fields.Boolean(required=False),
  322. },
  323. location="json",
  324. )
  325. @permission_required_for_context("create-children", ctx_arg_name="sensor")
  326. def trigger_schedule(
  327. self,
  328. sensor: Sensor,
  329. start_of_schedule: datetime,
  330. duration: timedelta,
  331. belief_time: datetime | None = None,
  332. flex_model: dict | None = None,
  333. flex_context: dict | None = None,
  334. force_new_job_creation: bool | None = False,
  335. **kwargs,
  336. ):
  337. """
  338. Trigger FlexMeasures to create a schedule.
  339. .. :quickref: Schedule; Trigger scheduling job
  340. Trigger FlexMeasures to create a schedule for this sensor.
  341. The assumption is that this sensor is the power sensor on a flexible asset.
  342. In this request, you can describe:
  343. - the schedule's main features (when does it start, what unit should it report, prior to what time can we assume knowledge)
  344. - the flexibility model for the sensor (state and constraint variables, e.g. current state of charge of a battery, or connection capacity)
  345. - the flexibility context which the sensor operates in (other sensors under the same EMS which are relevant, e.g. prices)
  346. For details on flexibility model and context, see :ref:`describing_flexibility`.
  347. Below, we'll also list some examples.
  348. .. note:: This endpoint does not support to schedule an EMS with multiple flexible sensors at once. This will happen in another endpoint.
  349. See https://github.com/FlexMeasures/flexmeasures/issues/485. Until then, it is possible to call this endpoint for one flexible endpoint at a time
  350. (considering already scheduled sensors as inflexible).
  351. The length of the schedule can be set explicitly through the 'duration' field.
  352. Otherwise, it is set by the config setting :ref:`planning_horizon_config`, which defaults to 48 hours.
  353. If the flex-model contains targets that lie beyond the planning horizon, the length of the schedule is extended to accommodate them.
  354. Finally, the schedule length is limited by :ref:`max_planning_horizon_config`, which defaults to 2520 steps of the sensor's resolution.
  355. Targets that exceed the max planning horizon are not accepted.
  356. The appropriate algorithm is chosen by FlexMeasures (based on asset type).
  357. It's also possible to use custom schedulers and custom flexibility models, see :ref:`plugin_customization`.
  358. If you have ideas for algorithms that should be part of FlexMeasures, let us know: https://flexmeasures.io/get-in-touch/
  359. **Example request A**
  360. This message triggers a schedule for a storage asset, starting at 10.00am, at which the state of charge (soc) is 12.1 kWh.
  361. .. code-block:: json
  362. {
  363. "start": "2015-06-02T10:00:00+00:00",
  364. "flex-model": {
  365. "soc-at-start": "12.1 kWh"
  366. }
  367. }
  368. **Example request B**
  369. This message triggers a 24-hour schedule for a storage asset, starting at 10.00am,
  370. at which the state of charge (soc) is 12.1 kWh, with a target state of charge of 25 kWh at 4.00pm.
  371. The charging efficiency is constant (120%) and the discharging efficiency is determined by the contents of sensor
  372. with id 98. If just the ``roundtrip-efficiency`` is known, it can be described with its own field.
  373. The global minimum and maximum soc are set to 10 and 25 kWh, respectively.
  374. To guarantee a minimum SOC in the period prior, the sensor with ID 300 contains beliefs at 2.00pm and 3.00pm, for 15kWh and 20kWh, respectively.
  375. Storage efficiency is set to 99.99%, denoting the state of charge left after each time step equal to the sensor's resolution.
  376. Aggregate consumption (of all devices within this EMS) should be priced by sensor 9,
  377. and aggregate production should be priced by sensor 10,
  378. where the aggregate power flow in the EMS is described by the sum over sensors 13, 14 and 15
  379. (plus the flexible sensor being optimized, of course).
  380. The battery consumption power capacity is limited by sensor 42 and the production capacity is constant (30 kW).
  381. Finally, the (contractual and physical) situation of the site is part of the flex-context.
  382. The site has a physical power capacity of 100 kVA, but the production capacity is limited to 80 kW,
  383. while the consumption capacity is limited by a dynamic capacity contract whose values are recorded under sensor 32.
  384. Breaching either capacity is penalized heavily in the optimization problem, with a price of 1000 EUR/kW.
  385. Finally, peaks over 50 kW in either direction are penalized with a price of 260 EUR/MW.
  386. These penalties can be used to steer the schedule into a certain behaviour (e.g. avoiding breaches and peaks),
  387. even if no direct financial impacts are expected at the given prices in the real world.
  388. For example, site owners may be requested by their network operators to reduce stress on the grid,
  389. be it explicitly or under a social contract.
  390. Note that, if forecasts for sensors 13, 14 and 15 are not available, a schedule cannot be computed.
  391. .. code-block:: json
  392. {
  393. "start": "2015-06-02T10:00:00+00:00",
  394. "duration": "PT24H",
  395. "flex-model": {
  396. "soc-at-start": "12.1 kWh",
  397. "state-of-charge" : {"sensor" : 24},
  398. "soc-targets": [
  399. {
  400. "value": "25 kWh",
  401. "datetime": "2015-06-02T16:00:00+00:00"
  402. },
  403. ],
  404. "soc-minima": {"sensor" : 300},
  405. "soc-min": "10 kWh",
  406. "soc-max": "25 kWh",
  407. "charging-efficiency": "120%",
  408. "discharging-efficiency": {"sensor": 98},
  409. "storage-efficiency": 0.9999,
  410. "power-capacity": "25kW",
  411. "consumption-capacity" : {"sensor": 42},
  412. "production-capacity" : "30 kW"
  413. },
  414. "flex-context": {
  415. "consumption-price": {"sensor": 9},
  416. "production-price": {"sensor": 10},
  417. "inflexible-device-sensors": [13, 14, 15],
  418. "site-power-capacity": "100 kVA",
  419. "site-production-capacity": "80 kW",
  420. "site-consumption-capacity": {"sensor": 32},
  421. "site-production-breach-price": "1000 EUR/kW",
  422. "site-consumption-breach-price": "1000 EUR/kW",
  423. "site-peak-consumption": "50 kW",
  424. "site-peak-production": "50 kW",
  425. "site-peak-consumption-price": "260 EUR/MW",
  426. "site-peak-production-price": "260 EUR/MW"
  427. }
  428. }
  429. **Example response**
  430. This message indicates that the scheduling request has been processed without any error.
  431. A scheduling job has been created with some Universally Unique Identifier (UUID),
  432. which will be picked up by a worker.
  433. The given UUID may be used to obtain the resulting schedule: see /sensors/<id>/schedules/<uuid>.
  434. .. sourcecode:: json
  435. {
  436. "status": "PROCESSED",
  437. "schedule": "364bfd06-c1fa-430b-8d25-8f5a547651fb",
  438. "message": "Request has been processed."
  439. }
  440. :reqheader Authorization: The authentication token
  441. :reqheader Content-Type: application/json
  442. :resheader Content-Type: application/json
  443. :status 200: PROCESSED
  444. :status 400: INVALID_DATA
  445. :status 401: UNAUTHORIZED
  446. :status 403: INVALID_SENDER
  447. :status 405: INVALID_METHOD
  448. :status 422: UNPROCESSABLE_ENTITY
  449. """
  450. end_of_schedule = start_of_schedule + duration
  451. scheduler_kwargs = dict(
  452. asset_or_sensor=sensor,
  453. start=start_of_schedule,
  454. end=end_of_schedule,
  455. resolution=sensor.event_resolution,
  456. belief_time=belief_time, # server time if no prior time was sent
  457. flex_model=flex_model,
  458. flex_context=flex_context,
  459. )
  460. try:
  461. job = create_scheduling_job(
  462. **scheduler_kwargs,
  463. enqueue=True,
  464. force_new_job_creation=force_new_job_creation,
  465. )
  466. except ValidationError as err:
  467. return invalid_flex_config(err.messages)
  468. except ValueError as err:
  469. return invalid_flex_config(str(err))
  470. db.session.commit()
  471. response = dict(schedule=job.id)
  472. d, s = request_processed()
  473. return dict(**response, **d), s
  474. @route("/<id>/schedules/<uuid>", methods=["GET"])
  475. @use_kwargs(
  476. {
  477. "sensor": SensorIdField(data_key="id"),
  478. "job_id": fields.Str(data_key="uuid"),
  479. },
  480. location="path",
  481. )
  482. @optional_duration_accepted(
  483. timedelta(hours=6)
  484. ) # todo: make this a Marshmallow field
  485. @permission_required_for_context("read", ctx_arg_name="sensor")
  486. def get_schedule( # noqa: C901
  487. self, sensor: Sensor, job_id: str, duration: timedelta, **kwargs
  488. ):
  489. """Get a schedule from FlexMeasures.
  490. .. :quickref: Schedule; Download schedule from the platform
  491. **Optional fields**
  492. - "duration" (6 hours by default; can be increased to plan further into the future)
  493. **Example response**
  494. This message contains a schedule indicating to consume at various power
  495. rates from 10am UTC onwards for a duration of 45 minutes.
  496. .. sourcecode:: json
  497. {
  498. "values": [
  499. 2.15,
  500. 3,
  501. 2
  502. ],
  503. "start": "2015-06-02T10:00:00+00:00",
  504. "duration": "PT45M",
  505. "unit": "MW"
  506. }
  507. :reqheader Authorization: The authentication token
  508. :reqheader Content-Type: application/json
  509. :resheader Content-Type: application/json
  510. :status 200: PROCESSED
  511. :status 400: INVALID_TIMEZONE, INVALID_DOMAIN, INVALID_UNIT, UNKNOWN_SCHEDULE, UNRECOGNIZED_CONNECTION_GROUP
  512. :status 401: UNAUTHORIZED
  513. :status 403: INVALID_SENDER
  514. :status 405: INVALID_METHOD
  515. :status 422: UNPROCESSABLE_ENTITY
  516. """
  517. planning_horizon = min( # type: ignore
  518. duration, current_app.config.get("FLEXMEASURES_PLANNING_HORIZON")
  519. )
  520. # Look up the scheduling job
  521. connection = current_app.queues["scheduling"].connection
  522. try: # First try the scheduling queue
  523. job = Job.fetch(job_id, connection=connection)
  524. except NoSuchJobError:
  525. return unrecognized_event(job_id, "job")
  526. if (
  527. not current_app.config.get("FLEXMEASURES_FALLBACK_REDIRECT")
  528. and job.is_failed
  529. and (job.meta.get("fallback_job_id") is not None)
  530. ):
  531. try: # First try the scheduling queue
  532. job = Job.fetch(job.meta["fallback_job_id"], connection=connection)
  533. except NoSuchJobError:
  534. current_app.logger.error(
  535. f"Fallback job with ID={job.meta['fallback_job_id']} (originator Job ID={job_id}) not found."
  536. )
  537. return unrecognized_event(job.meta["fallback_job_id"], "fallback-job")
  538. scheduler_info_msg = ""
  539. scheduler_info = job.meta.get("scheduler_info", dict(scheduler=""))
  540. scheduler_info_msg = f"{scheduler_info['scheduler']} was used."
  541. if job.is_finished:
  542. error_message = "A scheduling job has been processed with your job ID, but "
  543. elif job.is_failed: # Try to inform the user on why the job failed
  544. e = job.meta.get(
  545. "exception",
  546. Exception(
  547. "The job does not state why it failed. "
  548. "The worker may be missing an exception handler, "
  549. "or its exception handler is not storing the exception as job meta data."
  550. ),
  551. )
  552. message = f"Scheduling job failed with {type(e).__name__}: {e}. {scheduler_info_msg}"
  553. fallback_job_id = job.meta.get("fallback_job_id")
  554. # redirect to the fallback schedule endpoint if the fallback_job_id
  555. # is defined in the metadata of the original job
  556. if fallback_job_id is not None:
  557. return fallback_schedule_redirect(
  558. message,
  559. url_for(
  560. "SensorAPI:get_schedule",
  561. uuid=fallback_job_id,
  562. id=sensor.id,
  563. _external=True,
  564. ),
  565. )
  566. else:
  567. return unknown_schedule(message)
  568. elif job.is_started:
  569. return unknown_schedule(f"Scheduling job in progress. {scheduler_info_msg}")
  570. elif job.is_queued:
  571. return unknown_schedule(
  572. f"Scheduling job waiting to be processed. {scheduler_info_msg}"
  573. )
  574. elif job.is_deferred:
  575. try:
  576. preferred_job = job.dependency
  577. except NoSuchJobError:
  578. return unknown_schedule(
  579. f"Scheduling job waiting for unknown job to be processed. {scheduler_info_msg}"
  580. )
  581. return unknown_schedule(
  582. f'Scheduling job waiting for {preferred_job.status} job "{preferred_job.id}" to be processed. {scheduler_info_msg}'
  583. )
  584. else:
  585. return unknown_schedule(
  586. f"Scheduling job has an unknown status. {scheduler_info_msg}"
  587. )
  588. schedule_start = job.kwargs["start"]
  589. data_source = get_data_source_for_job(job)
  590. if data_source is None:
  591. return unknown_schedule(
  592. error_message
  593. + f"no data source could be found for {data_source}. {scheduler_info_msg}"
  594. )
  595. power_values = sensor.search_beliefs(
  596. event_starts_after=schedule_start,
  597. event_ends_before=schedule_start + planning_horizon,
  598. source=data_source,
  599. most_recent_beliefs_only=True,
  600. one_deterministic_belief_per_event=True,
  601. )
  602. sign = 1
  603. if sensor.get_attribute("consumption_is_positive", True):
  604. sign = -1
  605. # For consumption schedules, positive values denote consumption. For the db, consumption is negative
  606. consumption_schedule = sign * simplify_index(power_values)["event_value"]
  607. if consumption_schedule.empty:
  608. return unknown_schedule(
  609. f"{error_message} the schedule was not found in the database. {scheduler_info_msg}"
  610. )
  611. # Update the planning window
  612. resolution = sensor.event_resolution
  613. start = consumption_schedule.index[0]
  614. duration = min(duration, consumption_schedule.index[-1] + resolution - start)
  615. consumption_schedule = consumption_schedule[
  616. start : start + duration - resolution
  617. ]
  618. response = dict(
  619. values=consumption_schedule.tolist(),
  620. start=isodate.datetime_isoformat(start),
  621. duration=duration_isoformat(duration),
  622. unit=sensor.unit,
  623. )
  624. d, s = request_processed(scheduler_info_msg)
  625. return dict(scheduler_info=scheduler_info, **response, **d), s
  626. @route("/<id>", methods=["GET"])
  627. @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path")
  628. @permission_required_for_context("read", ctx_arg_name="sensor")
  629. @as_json
  630. def fetch_one(self, id, sensor):
  631. """Fetch a given sensor.
  632. .. :quickref: Sensor; Get a sensor
  633. This endpoint gets a sensor.
  634. **Example response**
  635. .. sourcecode:: json
  636. {
  637. "name": "some gas sensor",
  638. "unit": "m³/h",
  639. "entity_address": "ea1.2023-08.localhost:fm1.1",
  640. "event_resolution": "PT10M",
  641. "generic_asset_id": 4,
  642. "timezone": "UTC",
  643. "id": 2
  644. }
  645. :reqheader Authorization: The authentication token
  646. :reqheader Content-Type: application/json
  647. :resheader Content-Type: application/json
  648. :status 200: PROCESSED
  649. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  650. :status 401: UNAUTHORIZED
  651. :status 403: INVALID_SENDER
  652. :status 422: UNPROCESSABLE_ENTITY
  653. """
  654. return sensor_schema.dump(sensor), 200
  655. @route("", methods=["POST"])
  656. @use_args(sensor_schema)
  657. @permission_required_for_context(
  658. "create-children",
  659. ctx_arg_pos=1,
  660. ctx_arg_name="generic_asset_id",
  661. ctx_loader=GenericAsset,
  662. pass_ctx_to_loader=True,
  663. )
  664. def post(self, sensor_data: dict):
  665. """Create new sensor.
  666. .. :quickref: Sensor; Create a new Sensor
  667. This endpoint creates a new Sensor.
  668. **Example request**
  669. .. sourcecode:: json
  670. {
  671. "name": "power",
  672. "event_resolution": "PT1H",
  673. "unit": "kWh",
  674. "generic_asset_id": 1,
  675. }
  676. **Example response**
  677. The whole sensor is returned in the response:
  678. .. sourcecode:: json
  679. {
  680. "name": "power",
  681. "unit": "kWh",
  682. "entity_address": "ea1.2023-08.localhost:fm1.1",
  683. "event_resolution": "PT1H",
  684. "generic_asset_id": 1,
  685. "timezone": "UTC",
  686. "id": 2
  687. }
  688. :reqheader Authorization: The authentication token
  689. :reqheader Content-Type: application/json
  690. :resheader Content-Type: application/json
  691. :status 201: CREATED
  692. :status 400: INVALID_REQUEST
  693. :status 401: UNAUTHORIZED
  694. :status 403: INVALID_SENDER
  695. :status 422: UNPROCESSABLE_ENTITY
  696. """
  697. sensor = Sensor(**sensor_data)
  698. db.session.add(sensor)
  699. db.session.commit()
  700. asset = sensor_schema.context["generic_asset"]
  701. AssetAuditLog.add_record(asset, f"Created sensor '{sensor.name}': {sensor.id}")
  702. return sensor_schema.dump(sensor), 201
  703. @route("/<id>", methods=["PATCH"])
  704. @use_args(partial_sensor_schema)
  705. @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path")
  706. @permission_required_for_context("update", ctx_arg_name="sensor")
  707. @as_json
  708. def patch(self, sensor_data: dict, id: int, sensor: Sensor):
  709. """Update a sensor given its identifier.
  710. .. :quickref: Sensor; Update a sensor
  711. This endpoint updates the descriptive data of an existing sensor.
  712. Any subset of sensor fields can be sent.
  713. However, the following fields are not allowed to be updated:
  714. - id
  715. - generic_asset_id
  716. - entity_address
  717. Only admin users have rights to update the sensor fields. Be aware that changing unit, event resolution and knowledge horizon should currently only be done on sensors without existing belief data (to avoid a serious mismatch), or if you really know what you are doing.
  718. **Example request**
  719. .. sourcecode:: json
  720. {
  721. "name": "POWER",
  722. }
  723. **Example response**
  724. The whole sensor is returned in the response:
  725. .. sourcecode:: json
  726. {
  727. "name": "some gas sensor",
  728. "unit": "m³/h",
  729. "entity_address": "ea1.2023-08.localhost:fm1.1",
  730. "event_resolution": "PT10M",
  731. "generic_asset_id": 4,
  732. "timezone": "UTC",
  733. "id": 2
  734. }
  735. :reqheader Authorization: The authentication token
  736. :reqheader Content-Type: application/json
  737. :resheader Content-Type: application/json
  738. :status 200: UPDATED
  739. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  740. :status 401: UNAUTHORIZED
  741. :status 403: INVALID_SENDER
  742. :status 422: UNPROCESSABLE_ENTITY
  743. """
  744. audit_log_data = list()
  745. for k, v in sensor_data.items():
  746. if getattr(sensor, k) != v:
  747. audit_log_data.append(
  748. f"Field name: {k}, Old value: {getattr(sensor, k)}, New value: {v}"
  749. )
  750. audit_log_event = f"Updated sensor '{sensor.name}': {sensor.id}. Updated fields: {'; '.join(audit_log_data)}"
  751. AssetAuditLog.add_record(sensor.generic_asset, audit_log_event)
  752. for k, v in sensor_data.items():
  753. setattr(sensor, k, v)
  754. db.session.add(sensor)
  755. db.session.commit()
  756. return sensor_schema.dump(sensor), 200
  757. @route("/<id>", methods=["DELETE"])
  758. @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path")
  759. @permission_required_for_context("delete", ctx_arg_name="sensor")
  760. @as_json
  761. def delete(self, id: int, sensor: Sensor):
  762. """Delete a sensor given its identifier.
  763. .. :quickref: Sensor; Delete a sensor
  764. This endpoint deletes an existing sensor, as well as all measurements recorded for it.
  765. :reqheader Authorization: The authentication token
  766. :reqheader Content-Type: application/json
  767. :resheader Content-Type: application/json
  768. :status 204: DELETED
  769. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  770. :status 401: UNAUTHORIZED
  771. :status 403: INVALID_SENDER
  772. :status 422: UNPROCESSABLE_ENTITY
  773. """
  774. """Delete time series data."""
  775. db.session.execute(delete(TimedBelief).filter_by(sensor_id=sensor.id))
  776. AssetAuditLog.add_record(
  777. sensor.generic_asset, f"Deleted sensor '{sensor.name}': {sensor.id}"
  778. )
  779. sensor_name = sensor.name
  780. db.session.execute(delete(Sensor).filter_by(id=sensor.id))
  781. db.session.commit()
  782. current_app.logger.info("Deleted sensor '%s'." % sensor_name)
  783. return {}, 204
  784. @route("/<id>/stats", methods=["GET"])
  785. @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path")
  786. @use_kwargs(
  787. {"sort_keys": fields.Boolean(data_key="sort", load_default=True)},
  788. location="query",
  789. )
  790. @permission_required_for_context("read", ctx_arg_name="sensor")
  791. @as_json
  792. def get_stats(self, id, sensor: Sensor, sort_keys: bool):
  793. """Fetch stats for a given sensor.
  794. .. :quickref: Sensor; Get sensor stats
  795. This endpoint fetches sensor stats for all the historical data.
  796. Example response
  797. .. sourcecode:: json
  798. {
  799. "some data source": {
  800. "First event start": "2015-06-02T10:00:00+00:00",
  801. "Last event end": "2015-10-02T10:00:00+00:00",
  802. "Last recorded": "2015-10-02T10:01:12+00:00",
  803. "Min value": 0.0,
  804. "Max value": 100.0,
  805. "Mean value": 50.0,
  806. "Sum over values": 500.0,
  807. "Number of values": 10
  808. }
  809. }
  810. :reqheader Authorization: The authentication token
  811. :reqheader Content-Type: application/json
  812. :resheader Content-Type: application/json
  813. :status 200: PROCESSED
  814. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  815. :status 401: UNAUTHORIZED
  816. :status 403: INVALID_SENDER
  817. :status 422: UNPROCESSABLE_ENTITY
  818. """
  819. return get_sensor_stats(sensor, sort_keys), 200
  820. @route("/<id>/status", methods=["GET"])
  821. @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path")
  822. @permission_required_for_context("read", ctx_arg_name="sensor")
  823. @as_json
  824. def get_status(self, id, sensor):
  825. """
  826. Fetch the current status for a given sensor.
  827. .. :quickref: Sensor; Get sensor status
  828. This endpoint fetches the current status data for the specified sensor.
  829. The status includes information about the sensor's status, staleness and resolution.
  830. Example response:
  831. .. sourcecode:: json
  832. [
  833. {
  834. 'staleness': None,
  835. 'stale': True,
  836. 'staleness_since': None,
  837. 'reason': 'no data recorded',
  838. 'source_type': None,
  839. 'id': 64906,
  840. 'name': 'power',
  841. 'resolution': '15 minutes',
  842. 'asset_name': 'Location 1',
  843. 'relation': 'sensor belongs to this asset'
  844. }
  845. ]
  846. :reqheader Authorization: The authentication token
  847. :reqheader Content-Type: application/json
  848. :resheader Content-Type: application/json
  849. :status 200: PROCESSED
  850. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  851. :status 401: UNAUTHORIZED
  852. :status 403: INVALID_SENDER
  853. :status 404: ASSET_NOT_FOUND
  854. :status 422: UNPROCESSABLE_ENTITY
  855. """
  856. status_data = serialize_sensor_status_data(sensor=sensor)
  857. return {"sensors_data": status_data}, 200