Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API endpoint for scheduling asset #1065

Draft
wants to merge 71 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 57 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
e2fced5
feature: copy trigger_schedule endpoint from SensorAPI to AssetAPI
Flix6x May 16, 2024
bfdd94f
fix: update function signature
Flix6x May 16, 2024
5795e1b
fix: get rid of deprecation warning
Flix6x May 16, 2024
2e4cfe0
fix: update docstring
Flix6x May 16, 2024
2e28ed9
fix: imports
Flix6x May 16, 2024
f3711de
fix: obtain sensors from flex-model
Flix6x May 16, 2024
305d76d
fix: create one scheduling job for each sensor listed in the flex-model
Flix6x May 16, 2024
f6e8075
fix: add todos
Flix6x May 16, 2024
4bf91dc
docs: update endpoint main descriptions and quickrefs
Flix6x May 17, 2024
0917896
style: black
Flix6x May 17, 2024
753b411
fix: changelog syntax
Flix6x May 17, 2024
ba90dd1
fix: remove redundant session commit when calling the API to trigger …
Flix6x May 17, 2024
af4debf
docs: update FlexContextSchema docstring
Flix6x May 17, 2024
49a1a12
feature: helpful message for test developers
Flix6x May 17, 2024
8a3e112
feature: check auth on sensors referenced in flex-context
Flix6x May 17, 2024
25d1180
feature: allow checking permissions on optional fields
Flix6x May 17, 2024
21ac09a
feature: decorator supports custom error handler
Flix6x May 17, 2024
b4ad01a
docs: add inline note explaining status code
Flix6x May 17, 2024
5b4c30f
feature: flex_context_loader lists all sensors contained in a flex-co…
Flix6x May 17, 2024
484a022
feature: support context loader that returns multiple contexts
Flix6x May 17, 2024
8374a9c
feature: check permissions on sensors referenced in flex-context
Flix6x May 17, 2024
4621ef6
feature: add test checking permissions
Flix6x May 17, 2024
ff22fea
fix: response with field names
Flix6x May 17, 2024
be51f1b
add create_sequential_scheduling_job function
victorgarcia98 May 17, 2024
061e1f3
add fixtures
victorgarcia98 May 17, 2024
6eb122b
add test_create_sequential_jobs
victorgarcia98 May 17, 2024
b204e4b
Merge remote-tracking branch 'origin/feature/api/endpoint-for-schedul…
victorgarcia98 May 17, 2024
22834ca
Revert "fix: response with field names"
Flix6x May 24, 2024
dc07b11
Revert "feature: add test checking permissions"
Flix6x May 24, 2024
b583c89
Revert "feature: check permissions on sensors referenced in flex-cont…
Flix6x May 24, 2024
d04bed0
Revert "feature: support context loader that returns multiple contexts"
Flix6x May 24, 2024
5fd2f63
Revert "feature: flex_context_loader lists all sensors contained in a…
Flix6x May 24, 2024
ee5e19e
Revert "docs: add inline note explaining status code"
Flix6x May 24, 2024
c2bf1af
Revert "feature: decorator supports custom error handler"
Flix6x May 24, 2024
529baa2
Revert "feature: allow checking permissions on optional fields"
Flix6x May 24, 2024
1665974
Revert "feature: check auth on sensors referenced in flex-context"
Flix6x May 24, 2024
8678a0d
docs: add inline note explaining permission decorator
Flix6x May 24, 2024
f42ceaf
fix: remove unused parameter (does not need to be formally deprecated…
Flix6x May 24, 2024
7b5b3ce
feature: check that each flexible device power sensor lives under the…
Flix6x May 24, 2024
e9e6ec9
Merge branch 'main' into feature/api/endpoint-for-scheduling-asset
victorgarcia98 May 30, 2024
378b0a7
docs: changelog entry
Flix6x Jun 3, 2024
754502a
docs: API changelog entry
Flix6x Jun 3, 2024
e1e1019
feature: allow loading API data from three places, and avoid clashing…
Flix6x Jun 4, 2024
4f86bb1
docs: typos
Flix6x Jun 4, 2024
de811ec
refactor: parameterize test cases
Flix6x Jun 5, 2024
175cd18
refactor: merge AssetIdField and GenericAssetIDField
Flix6x Jun 5, 2024
ffe82b1
refactor: move to AssetTriggerSchema
Flix6x Jun 5, 2024
45592c7
remove
Flix6x Jun 5, 2024
99bd8da
remove: seemingly obsolete workaround
Flix6x Jun 5, 2024
44cfd0c
feature: validate flex-model sensors belong to asset
Flix6x Jun 5, 2024
ee08883
feature: more clearly separate the serialized and deserialized parts …
Flix6x Jun 5, 2024
a9ddce8
feature: add end-to-end test for triggering and getting a schedule
Flix6x Jun 5, 2024
f340a95
docs: add fixture docstring explaining partial deserialization
Flix6x Jun 5, 2024
4c6c52a
style: black
Flix6x Jun 5, 2024
b328921
fix: in case no inflexible-device-sensors were part of the flex-context
Flix6x Jun 5, 2024
b40d40e
docs: fix inline comment
Flix6x Jun 5, 2024
b7961f4
fix: job enqueueing
Flix6x Jun 5, 2024
429d97f
style: black
Flix6x Jun 5, 2024
8423522
refactor: rename argument; sequential scheduling is only for assets
Flix6x Jun 5, 2024
c60f5cf
docs: explain assert statement
Flix6x Jun 5, 2024
8ba79aa
Merge remote-tracking branch 'refs/remotes/origin/main' into feature/…
Flix6x Jun 5, 2024
9f5eb6a
style: black
Flix6x Jun 5, 2024
71da5c4
handle fallback and rescheduling
victorgarcia98 Jun 5, 2024
07b44b6
style: flake8
Flix6x Jun 5, 2024
a25ab4d
Merge remote-tracking branch 'origin/feature/api/endpoint-for-schedul…
victorgarcia98 Jun 5, 2024
5f9a4dc
fix: clarify join to avoid `sqlalchemy.exc.AmbiguousForeignKeysError`
Flix6x Jun 5, 2024
1f26fa2
Merge remote-tracking branch 'origin/feature/api/endpoint-for-schedul…
victorgarcia98 Jun 5, 2024
8b0c0d0
fix merge
victorgarcia98 Jun 5, 2024
edf38a8
Merge branch 'main' into feature/api/endpoint-for-scheduling-asset
victorgarcia98 Jun 5, 2024
05064ae
fix: remove 404, because the `asset` field should be seen as part of …
Flix6x Jun 5, 2024
c7e58cb
feature: avoid having magic number for status codes
Flix6x Jun 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions documentation/api/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ API change log
.. note:: The FlexMeasures API follows its own versioning scheme. This is also reflected in the URL (e.g. `/api/v3_0`), allowing developers to upgrade at their own pace.


v3.0-19 | 2024-06-03
""""""""""""""""""""
- New API endpoint `[POST] /assets/(id)/schedules/trigger <api/v3_0.html#post--api-v3_0-assets-(id)-schedules-trigger>`_ to schedule a site with multiple flexible devices.
- Updated message for 404 Not Found on endpoints for managing assets: `/assets` (GET, POST) and `/assets/<id>` (GET, PATCH, DELETE).


v3.0-18 | 2024-03-07
""""""""""""""""""""
- Add support for providing a sensor definition to the ``soc-minima``, ``soc-maxima`` and ``soc-targets`` flex-model fields for `/sensors/<id>/schedules/trigger` (POST).
Expand Down
2 changes: 1 addition & 1 deletion documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ v0.22.0 | June XX, 2024

New features
-------------

* New API endpoint `[POST] /assets/(id)/schedules/trigger <api/v3_0.html#post--api-v3_0-assets-(id)-schedules-trigger>`_ to schedule a site with multiple flexible devices [see `PR #1065 <https://github.com/FlexMeasures/flexmeasures/pull/1065/>`_]

Infrastructure / Support
----------------------
Expand Down
23 changes: 0 additions & 23 deletions flexmeasures/api/common/schemas/generic_assets.py

This file was deleted.

22 changes: 19 additions & 3 deletions flexmeasures/api/common/utils/args_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,26 @@ def validation_error_handler(error: FMValidationError):
@parser.location_loader("args_and_json")
def load_data(request, schema):
"""
We allow parameters to come from either GET args or POST JSON,
as validators can be attached to either.
We allow parameters to come from URL path, GET args and POST JSON,
as validators can be attached to any of them.
"""

# GET args (i.e. query parameters, such as https://flexmeasures.io/?id=5)
newdata = request.args.copy()

# View args (i.e. path parameters, such as the `/assets/<id>` endpoint)
path_params = request.view_args
# Avoid clashes such as visiting https://flexmeasures.io/assets/4/?id=5 on the /assets/<id> endpoint
for key in path_params:
if key in newdata:
raise FMValidationError(message=f"{key} already set in the URL path")
newdata.update(path_params)

if request.mimetype == "application/json" and request.method == "POST":
newdata.update(request.get_json())
json_params = request.get_json()
# Avoid clashes
for key in json_params:
if key in newdata:
raise FMValidationError(message=f"{key} already set in the URL path or query parameters")
newdata.update(json_params)
return MultiDictProxy(newdata, schema)
9 changes: 8 additions & 1 deletion flexmeasures/api/tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from pytest import UsageError

import json

from flask import url_for, current_app, Response
Expand Down Expand Up @@ -71,7 +73,12 @@ class UserContext(object):
"""

def __init__(self, user_email: str):
self.the_user = find_user_by_email(user_email)
user = find_user_by_email(user_email)
if user is None:
raise UsageError(
f"no user with email {user_email} found - test is possible missing a fixture that sets up this user",
)
self.the_user = user

def __enter__(self):
return self.the_user
Expand Down
208 changes: 199 additions & 9 deletions flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
from __future__ import annotations

from datetime import datetime, timedelta
import json

from flask import current_app
from flask_classful import FlaskView, route
from flask_security import auth_required
from flask_json import as_json
from marshmallow import fields
from marshmallow import fields, ValidationError
from webargs.flaskparser import use_kwargs, use_args
from sqlalchemy import select, delete

from flexmeasures.auth.decorators import permission_required_for_context
from flexmeasures.data import db
from flexmeasures.data.models.user import Account
from flexmeasures.data.models.generic_assets import GenericAsset
from flexmeasures.data.schemas import AwareDateTimeField
from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema
from flexmeasures.api.common.schemas.generic_assets import AssetIdField
from flexmeasures.data.schemas.generic_assets import (
GenericAssetSchema as AssetSchema,
GenericAssetIdField as AssetIdField,
)
from flexmeasures.data.schemas.scheduling import AssetTriggerSchema
from flexmeasures.data.schemas.times import AwareDateTimeField
from flexmeasures.data.services.scheduling import (
create_sequential_scheduling_job,
)
from flexmeasures.api.common.schemas.users import AccountIdField
from flexmeasures.api.common.responses import (
invalid_flex_config,
request_processed,
)
from flexmeasures.utils.coding_utils import flatten_unique
from flexmeasures.ui.utils.view_utils import set_session_variables

Expand Down Expand Up @@ -146,7 +159,9 @@ def post(self, asset_data: dict):
return asset_schema.dump(asset), 201

@route("/<id>", methods=["GET"])
@use_kwargs({"asset": AssetIdField(data_key="id")}, location="path")
@use_kwargs(
{"asset": AssetIdField(data_key="id", status_if_not_found=404)}, location="path"
)
@permission_required_for_context("read", ctx_arg_name="asset")
@as_json
def fetch_one(self, id, asset):
Expand Down Expand Up @@ -182,7 +197,10 @@ def fetch_one(self, id, asset):

@route("/<id>", methods=["PATCH"])
@use_args(partial_asset_schema)
@use_kwargs({"db_asset": AssetIdField(data_key="id")}, location="path")
@use_kwargs(
{"db_asset": AssetIdField(data_key="id", status_if_not_found=404)},
location="path",
)
@permission_required_for_context("update", ctx_arg_name="db_asset")
@as_json
def patch(self, asset_data: dict, id: int, db_asset: GenericAsset):
Expand Down Expand Up @@ -238,7 +256,9 @@ def patch(self, asset_data: dict, id: int, db_asset: GenericAsset):
return asset_schema.dump(db_asset), 200

@route("/<id>", methods=["DELETE"])
@use_kwargs({"asset": AssetIdField(data_key="id")}, location="path")
@use_kwargs(
{"asset": AssetIdField(data_key="id", status_if_not_found=404)}, location="path"
)
@permission_required_for_context("delete", ctx_arg_name="asset")
@as_json
def delete(self, id: int, asset: GenericAsset):
Expand All @@ -265,7 +285,7 @@ def delete(self, id: int, asset: GenericAsset):

@route("/<id>/chart", strict_slashes=False) # strict on next version? see #1014
@use_kwargs(
{"asset": AssetIdField(data_key="id")},
{"asset": AssetIdField(data_key="id", status_if_not_found=404)},
location="path",
)
@use_kwargs(
Expand Down Expand Up @@ -295,7 +315,7 @@ def get_chart(self, id: int, asset: GenericAsset, **kwargs):
"/<id>/chart_data", strict_slashes=False
) # strict on next version? see #1014
@use_kwargs(
{"asset": AssetIdField(data_key="id")},
{"asset": AssetIdField(data_key="id", status_if_not_found=404)},
location="path",
)
@use_kwargs(
Expand All @@ -318,3 +338,173 @@ def get_chart_data(self, id: int, asset: GenericAsset, **kwargs):
"""
sensors = flatten_unique(asset.sensors_to_show)
return asset.search_beliefs(sensors=sensors, as_json=True, **kwargs)

@route("/<id>/schedules/trigger", methods=["POST"])
@use_args(AssetTriggerSchema(), location="args_and_json", as_kwargs=True)
# Simplification of checking for create-children access on each of the flexible sensors,
# which assumes each of the flexible sensors belongs to the given asset.
@permission_required_for_context("create-children", ctx_arg_name="asset")
def trigger_schedule(
self,
asset: GenericAsset,
start_of_schedule: datetime,
duration: timedelta,
belief_time: datetime | None = None,
flex_model: dict | None = None,
flex_context: dict | None = None,
**kwargs,
):
"""
Trigger FlexMeasures to create a schedule for a collection of flexible devices.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trigger can also deal with inflexible devices so this is incomplete.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first part is true. However, no schedules are created for the inflexible devices.


.. :quickref: Schedule; Trigger scheduling job for multiple devices

Trigger FlexMeasures to create a schedule for this asset.
The assumption is that this is a flexible asset containing multiple power sensors.

In this request, you can describe:

- the schedule's main features (when does it start, what unit should it report, prior to what time can we assume knowledge)
- the flexibility models for the asset's relevant sensors (state and constraint variables, e.g. current state of charge of a battery, or connection capacity)
- the flexibility context which the asset operates in (other sensors under the same EMS which are relevant, e.g. prices)

For details on flexibility model and context, see :ref:`describing_flexibility`.
Below, we'll also list some examples.

.. note:: This endpoint support scheduling an EMS with multiple flexible sensors at once,
but internally, it does so sequentially
(considering already scheduled sensors as inflexible).

The length of the schedule can be set explicitly through the 'duration' field.
Otherwise, it is set by the config setting :ref:`planning_horizon_config`, which defaults to 48 hours.
If the flex-model contains targets that lie beyond the planning horizon, the length of the schedule is extended to accommodate them.
Finally, the schedule length is limited by :ref:`max_planning_horizon_config`, which defaults to 2520 steps of each sensor's resolution.
Targets that exceed the max planning horizon are not accepted.

The appropriate algorithm is chosen by FlexMeasures (based on asset type).
It's also possible to use custom schedulers and custom flexibility models, see :ref:`plugin_customization`.

If you have ideas for algorithms that should be part of FlexMeasures, let us know: https://flexmeasures.io/get-in-touch/

**Example request A**

This message triggers a schedule for a storage asset, starting at 10.00am, at which the state of charge (soc) is 12.1 kWh.

.. code-block:: json

{
"start": "2015-06-02T10:00:00+00:00",
"flex-model": [
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example should be updated with the new schema changes (sensor_flex_model field, etc)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sensor_flex_model field is not a field in the public API, but rather a field that gets created when the endpoint validation partly deserializes the flex-model field. It essentially belongs to a schema that is internal to our platform, used only for the purpose of sequential scheduling. The endpoint logic picks up the still serialized sensor_flex_model field and passes it to the job kwargs.

"sensor": 931,
"soc-at-start": 12.1,
"soc-unit": "kWh"
}
]
}

**Example request B**

This message triggers a 24-hour schedule for a storage asset, starting at 10.00am,
at which the state of charge (soc) is 12.1 kWh, with a target state of charge of 25 kWh at 4.00pm.

The charging efficiency is constant (120%) and the discharging efficiency is determined by the contents of sensor
with id 98. If just the ``roundtrip-efficiency`` is known, it can be described with its own field.
The global minimum and maximum soc are set to 10 and 25 kWh, respectively.
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.
Storage efficiency is set to 99.99%, denoting the state of charge left after each time step equal to the sensor's resolution.
Aggregate consumption (of all devices within this EMS) should be priced by sensor 9,
and aggregate production should be priced by sensor 10,
where the aggregate power flow in the EMS is described by the sum over sensors 13, 14 and 15
(plus the flexible sensor being optimized, of course).


The battery consumption power capacity is limited by sensor 42 and the production capacity is constant (30 kW).
Finally, the site consumption capacity is limited by sensor 32.

Note that, if forecasts for sensors 13, 14 and 15 are not available, a schedule cannot be computed.

.. code-block:: json

{
"start": "2015-06-02T10:00:00+00:00",
"duration": "PT24H",
"flex-model": [
{
"sensor": 931,
"soc-at-start": 12.1,
"soc-unit": "kWh",
"soc-targets": [
{
"value": 25,
"datetime": "2015-06-02T16:00:00+00:00"
},
],
"soc-minima": {"sensor" : 300},
"soc-min": 10,
"soc-max": 25,
"charging-efficiency": "120%",
"discharging-efficiency": {"sensor": 98},
"storage-efficiency": 0.9999,
"power-capacity": "25kW",
"consumption-capacity" : {"sensor": 42},
"production-capacity" : "30 kW"
},
],
"flex-context": {
"consumption-price-sensor": 9,
"production-price-sensor": 10,
"inflexible-device-sensors": [13, 14, 15],
"site-power-capacity": "100kW",
"site-production-capacity": "80kW",
"site-consumption-capacity": {"sensor": 32}
}
}

**Example response**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO


This message indicates that the scheduling request has been processed without any error.
A scheduling job has been created with some Universally Unique Identifier (UUID),
which will be picked up by a worker.
The given UUID may be used to obtain the resulting schedule: see /assets/<id>/schedules/<uuid>.

.. sourcecode:: json

{
"status": "PROCESSED",
"schedule": "364bfd06-c1fa-430b-8d25-8f5a547651fb",
"message": "Request has been processed."
}

:reqheader Authorization: The authentication token
:reqheader Content-Type: application/json
:resheader Content-Type: application/json
:status 200: PROCESSED
:status 400: INVALID_DATA
:status 401: UNAUTHORIZED
:status 403: INVALID_SENDER
:status 405: INVALID_METHOD
:status 422: UNPROCESSABLE_ENTITY
"""
end_of_schedule = start_of_schedule + duration

scheduler_kwargs = dict(
start=start_of_schedule,
end=end_of_schedule,
belief_time=belief_time, # server time if no prior time was sent
flex_model=flex_model,
flex_context=flex_context,
)
try:
jobs = create_sequential_scheduling_job(
asset_or_sensor=asset, enqueue=True, **scheduler_kwargs
)
except ValidationError as err:
return invalid_flex_config(err.messages)
except ValueError as err:
return invalid_flex_config(str(err))

# todo: make a 'done job' and pass that job's ID here
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, I guess I now implemented this todo, by putting the last job ID (that of the 'done job') in the response. However, I recall we discussed returning a full list of job IDs, so that the API user can use the existing get_schedule endpoint to retrieve schedules for individual sensors. In that case, the next step would be to spec such a response.

For example:

{
    "schedules": [
        {
            "sensor": 1,
            "schedule": "<uuid>"
        },
        {
            "sensor": 2,
            "schedule": "<uuid>"
        }
    ]
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, returning a list of schedules would be the way to go. That way, we can reuse the endpoint to get the schedules.

response = dict(schedule=jobs[-1].id)
d, s = request_processed()
return dict(**response, **d), s
Loading
Loading