Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 47 additions & 11 deletions flexmeasures_s2/api/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,52 @@
from flask import url_for
from flask_security import decorators as fs_decorators
from rq.job import Job

from flexmeasures.api.tests.test_auth_token import patched_check_token
from flexmeasures.api.tests.utils import UserContext
from flexmeasures.data.services.scheduling import (
handle_scheduling_exception,
get_data_source_for_job,
)
from flexmeasures.data.tests.utils import work_on_rq

def test_get_somedata_needs_authtoken(client):
response = client.get(
url_for("flexmeasures-s2 API.somedata"),
headers={"content-type": "application/json"},
follow_redirects=True,
)
assert response.status_code == 401 # HTTP error code 401 Unauthorized.
assert "application/json" in response.content_type
assert "not be properly authenticated" in response.json["message"]

def test_s2_frbc_api(monkeypatch, app, setup_frbc_asset):
sensor = setup_frbc_asset.sensors[0]

# TODO: The somedata endpoint requires authentication to be testes successfully.
# We'll need to add a user in conftest, which also requires us to add a db to testing
with UserContext("test_admin_user@seita.nl") as admin:
auth_token = admin.get_auth_token()

monkeypatch.setattr(fs_decorators, "_check_token", patched_check_token)
with app.test_client() as client:
trigger_schedule_response = client.post(
url_for("SensorAPI:trigger_schedule", id=sensor.id),
json={
"flex-context": {
"target-profile": {}, # add target profile
}
},
headers={"Authorization": auth_token},
)
print("Server responded with:\n%s" % trigger_schedule_response.json)
assert trigger_schedule_response.status_code == 200
job_id = trigger_schedule_response.json["schedule"]

# Now that our scheduling job was accepted, we process the scheduling queue
work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception)
job = Job.fetch(job_id, connection=app.queues["scheduling"].connection).is_finished
assert job.is_finished is True

# First, make sure the expected scheduler data source is now there
job.refresh() # catch meta info that was added on this very instance
scheduler_source = get_data_source_for_job(job)
assert scheduler_source.model == "S2Scheduler"

# try to retrieve the schedule through the /sensors/<id>/schedules/<job_id> [GET] api endpoint
# todo: to be discussed: the response from the S2Scheduler might get a different format than the FM default
# get_schedule_response = client.get(
# url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id),
# query_string={"duration": "PT48H"},
# )
# print("Server responded with:\n%s" % get_schedule_response.json)
# assert get_schedule_response.status_code == 200
54 changes: 52 additions & 2 deletions flexmeasures_s2/conftest.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import pytest

from flask_sqlalchemy import SQLAlchemy

from flexmeasures import Asset, AssetType, Sensor, Account
from flexmeasures.app import create as create_flexmeasures_app
from flexmeasures.auth.policy import ADMIN_ROLE
from flexmeasures.conftest import ( # noqa F401
db,
fresh_db,
) # Use these fixtures to rely on the FlexMeasures database.
) # Use these fixtures to rely on the FlexMeasures database. There might be others in flexmeasures/conftest you want to also re-use
from flexmeasures.data.services.users import create_user

from flexmeasures_s2 import S2_SCHEDULER_SPECS
from flexmeasures_s2.models.const import FRBC_TYPE

# There might be others in flexmeasures/conftest you want to also re-use
from flexmeasures_s2.scheduler.test_frbc_device import (
example_serialized_device_state,
) # noqa: F401


@pytest.fixture(scope="session")
Expand All @@ -25,3 +35,43 @@ def app():
ctx.pop()

print("DONE WITH APP FIXTURE")


@pytest.fixture(scope="module")
def setup_admin(db: SQLAlchemy): # noqa: F811
account = Account(name="Some FlexMeasures host")
db.session.add(account)
create_user(
username="Test Admin User",
email="test_admin_user@seita.nl",
account_name=account.name,
password="testtest",
user_roles=dict(name=ADMIN_ROLE, description="A user who can do everything."),
)
yield account


@pytest.fixture(scope="module")
def setup_frbc_asset(db: SQLAlchemy, setup_admin): # noqa: F811
asset_type = AssetType(name=FRBC_TYPE)
asset = Asset(
name="Test FRBC asset",
generic_asset_type=asset_type,
owner=setup_admin,
)
asset.attributes = {
"custom-scheduler": S2_SCHEDULER_SPECS,
"flex-model": {
"S2-FRBC-device-state": example_serialized_device_state, # ?todo: add serialized state
},
}
db.session.add(asset)
sensor = Sensor(
name="power",
unit="kW",
event_resolution="PT5M",
generic_asset=asset,
)
db.session.add(sensor)
db.session.flush() # assign (asset and sensor) IDs
yield asset
Empty file.
2 changes: 2 additions & 0 deletions flexmeasures_s2/models/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
NAMESPACE = "fm-s2"
FRBC_TYPE = f"{NAMESPACE}.FRBC"
10 changes: 10 additions & 0 deletions flexmeasures_s2/scheduler/s2_frbc_device_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from s2python.frbc import (
FRBCSystemDescription,
)

from typing import List
from pydantic import BaseModel


class S2FrbcDeviceState(BaseModel):
system_descriptions: List[FRBCSystemDescription]
15 changes: 15 additions & 0 deletions flexmeasures_s2/scheduler/schedulers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import pandas as pd
from flexmeasures import Scheduler

from flexmeasures_s2.scheduler.schemas import S2FlexModelSchema, TNOFlexContextSchema


class S2Scheduler(Scheduler):

Expand All @@ -12,6 +14,7 @@ def compute(self, *args, **kwargs):
Just a dummy scheduler that always plans to consume at maximum capacity.
(Schedulers return positive values for consumption, and negative values for production)
"""
raise NotImplementedError("todo: implement scheduling logic")
return pd.Series(
self.sensor.get_attribute("capacity_in_mw"),
index=pd.date_range(
Expand All @@ -21,4 +24,16 @@ def compute(self, *args, **kwargs):

def deserialize_config(self):
"""Do not care about any flex config sent in."""
# Find flex-model in asset attributes
self.flex_model = self.asset.attributes.get("flex-model", {})

self.deserialize_flex_config()
self.config_deserialized = True

def deserialize_flex_config(self):
"""Deserialize flex-model and flex-context"""
# Deserialize flex-model
self.flex_model = S2FlexModelSchema().load(self.flex_model)

# Deserialize self.flex_context
self.flex_context = TNOFlexContextSchema().load(self.flex_context)
17 changes: 14 additions & 3 deletions flexmeasures_s2/scheduler/schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
from marshmallow import Schema
from flexmeasures.data.schemas import AwareDateTimeField, DurationField

from marshmallow import Schema, fields

class S2FlexModelSchema(Schema):
...

class S2FlexModelSchema(Schema): ...


class TNOTargetProfile(Schema):
start = AwareDateTimeField()
duration = DurationField()
values = fields.List(fields.Float)


class TNOFlexContextSchema(Schema):
target_profile = fields.Nested(TNOTargetProfile())
123 changes: 123 additions & 0 deletions flexmeasures_s2/scheduler/test_frbc_device.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
{
"system_descriptions": [
{
"message_type": "FRBC.SystemDescription",
"message_id": "f6769b77-3d85-4d61-8a28-3bd70fc5cef4",
"valid_from": "2025-01-21 17:52:08.121054+00:00",
"actuators": [
{
"id": "1ebd596b-3031-403f-95a1-9520550b161b",
"diagnostic_label": "charge",
"supported_commodities": [
"Commodity.ELECTRICITY"
],
"operation_modes": [
{
"id": "charge.on",
"diagnostic_label": "charge.on",
"elements": [
{
"fill_level_range": {
"start_of_range": 0.0,
"end_of_range": 100.0
},
"fill_rate": {
"start_of_range": 0.0054012349,
"end_of_range": 0.0054012349
},
"power_ranges": [
{
"start_of_range": 28000.0,
"end_of_range": 28000.0,
"commodity_quantity": "CommodityQuantity.ELECTRIC_POWER_L1"
}
],
"running_costs": null
}
],
"abnormal_condition_only": false
},
{
"id": "charge.off",
"diagnostic_label": "charge.off",
"elements": [
{
"fill_level_range": {
"start_of_range": 0.0,
"end_of_range": 100.0
},
"fill_rate": {
"start_of_range": 0.0,
"end_of_range": 0.0
},
"power_ranges": [
{
"start_of_range": 0.0,
"end_of_range": 0.0,
"commodity_quantity": "CommodityQuantity.ELECTRIC_POWER_L1"
}
],
"running_costs": null
}
],
"abnormal_condition_only": false
}
],
"transitions": [
{
"id": "off.to.on",
"from_": "charge.off",
"to": "charge.on",
"start_timers": [
"on.to.off.timer"
],
"blocking_timers": [
"off.to.on.timer"
],
"transition_costs": null,
"transition_duration": null,
"abnormal_condition_only": false
},
{
"id": "on.to.off",
"from_": "charge.on",
"to": "charge.off",
"start_timers": [
"off.to.on.timer"
],
"blocking_timers": [
"on.to.off.timer"
],
"transition_costs": null,
"transition_duration": null,
"abnormal_condition_only": false
}
],
"timers": [
{
"id": "on.to.off.timer",
"diagnostic_label": "on.to.off.timer",
"duration": 30
},
{
"id": "off.to.on.timer",
"diagnostic_label": "off.to.on.timer",
"duration": 30
}
]
}
],
"storage": {
"diagnostic_label": "battery",
"fill_level_label": "SoC %",
"provides_leakage_behaviour": false,
"provides_fill_level_target_profile": true,
"provides_usage_forecast": false,
"fill_level_range": {
"start_of_range": 0.0,
"end_of_range": 100.0
}
}
}
]
}
Loading
Loading