From d9acea3eed23776dac581d2ee684c0893b9a7232 Mon Sep 17 00:00:00 2001 From: VladIftime <49650168+VladIftime@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:52:11 +0100 Subject: [PATCH 1/7] Update README.rst Signed-off-by: VladIftime <49650168+VladIftime@users.noreply.github.com> --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 2d4fd955..3785061d 100644 --- a/README.rst +++ b/README.rst @@ -141,7 +141,7 @@ Making Changes & Contributing Install the project locally (in a virtual environment of your choice):: - pip install -e + pip install -e . Running tests locally is crucial as well. Staying close to the CI workflow:: From c32e0f1cdea94b29f423ca336d64deeef2307d3b Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 10 Dec 2024 21:01:21 +0100 Subject: [PATCH 2/7] client.get_accout() --- src/flexmeasures_client/client.py | 27 +++++++++++ src/flexmeasures_client/s2/cem.py | 9 ++-- .../s2/script/demo_setup.py | 4 +- .../s2/script/websockets_client.py | 45 +++++++++++++------ .../s2/script/websockets_server.py | 10 +++-- tests/test_client.py | 16 +++++++ tests/test_s2_coordinator.py | 2 +- 7 files changed, 89 insertions(+), 24 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 0360d38e..c3f403c7 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -269,6 +269,7 @@ async def post_measurements( prior: str | None = None, ): """ + This function raises a ContentTypeError when the response is not a dictionary. Post sensor data for the given time range. This function raises a ValueError when an unhandled status code is returned """ @@ -321,6 +322,32 @@ async def get_schedule( ) return schedule + async def get_account(self) -> list[dict]: + """Get the account of the current user. + + :returns: account as dictionary, for example: + { + 'id': 1, + 'name': 'FlexMeasures', + + } + """ + + account_data, status = await self.request(uri="accounts", method="GET") + check_for_status(status, 200) + + # Return just the 'account_roles','id' and 'name' fields + account_data = [ + { + "account_roles": account["account_roles"], + "id": account["id"], + "name": account["name"], + } + for account in account_data + ] + + return account_data + async def get_assets(self) -> list[dict]: """Get all the assets available to the current user. diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index c64bb828..56384dea 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -42,7 +42,10 @@ class CEM(Handler): _sending_queue: Queue[pydantic.BaseModel] def __init__( - self, fm_client: FlexMeasuresClient, logger: Logger | None = None + self, + sensor_id: int, + fm_client: FlexMeasuresClient, + logger: Logger | None = None, ) -> None: """ Customer Energy Manager (CEM) @@ -223,9 +226,7 @@ def handle_handshake(self, message: Handshake): def handle_resource_manager_details(self, message: ResourceManagerDetails): self._resource_manager_details = message - if ( - not self._control_type - ): # initializing. TODO: check if sending resource_manager_details + if not self._control_type: # TODO: check if sending resource_manager_details # resets control type self._control_type = ControlType.NO_SELECTION diff --git a/src/flexmeasures_client/s2/script/demo_setup.py b/src/flexmeasures_client/s2/script/demo_setup.py index 3524c037..6c3fb430 100644 --- a/src/flexmeasures_client/s2/script/demo_setup.py +++ b/src/flexmeasures_client/s2/script/demo_setup.py @@ -3,8 +3,8 @@ from flexmeasures_client.client import FlexMeasuresClient client = FlexMeasuresClient( - email="admin@admin.com", - password="admin", + email="toy-user@flexmeasures.io", + password="toy-password", host="localhost:5000", ) diff --git a/src/flexmeasures_client/s2/script/websockets_client.py b/src/flexmeasures_client/s2/script/websockets_client.py index cf3f0cc3..9773cae3 100644 --- a/src/flexmeasures_client/s2/script/websockets_client.py +++ b/src/flexmeasures_client/s2/script/websockets_client.py @@ -3,34 +3,51 @@ import aiohttp import pytz - -from flexmeasures_client.s2.python_s2_protocol.common.messages import ( - Handshake, - ReceptionStatus, - ReceptionStatusValues, - ResourceManagerDetails, -) -from flexmeasures_client.s2.python_s2_protocol.common.schemas import ( +from s2python.common import ( Commodity, CommodityQuantity, ControlType, Duration, EnergyManagementRole, + Handshake, NumberRange, PowerRange, + ReceptionStatus, + ReceptionStatusValues, + ResourceManagerDetails, Role, RoleType, ) -from flexmeasures_client.s2.python_s2_protocol.FRBC.messages import ( - FRBCStorageStatus, - FRBCSystemDescription, -) -from flexmeasures_client.s2.python_s2_protocol.FRBC.schemas import ( +from s2python.frbc import ( FRBCActuatorDescription, FRBCOperationMode, FRBCOperationModeElement, FRBCStorageDescription, + FRBCStorageStatus, + FRBCSystemDescription, ) + +# from flexmeasures_client.s2.python_s2_protocol.common.schemas import ( +# Commodity, +# CommodityQuantity, +# ControlType, +# Duration, +# EnergyManagementRole, +# NumberRange, +# PowerRange, +# Role, +# RoleType, +# ) +# from flexmeasures_client.s2.python_s2_protocol.FRBC.messages import ( +# FRBCStorageStatus, +# FRBCSystemDescription, +# ) +# from flexmeasures_client.s2.python_s2_protocol.FRBC.schemas import ( +# FRBCActuatorDescription, +# FRBCOperationMode, +# FRBCOperationModeElement, +# FRBCStorageDescription, +# ) from flexmeasures_client.s2.utils import get_unique_id @@ -59,7 +76,7 @@ async def main_s2(): roles=[ Role(role=RoleType.ENERGY_STORAGE, commodity=Commodity.ELECTRICITY) ], - instruction_processing_delay=Duration(__root__=1.0), + instruction_processing_delay=Duration(__root__=1), available_control_types=[ ControlType.FILL_RATE_BASED_CONTROL, ControlType.NO_SELECTION, diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 8591f6a8..c65f9373 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -3,11 +3,13 @@ import aiohttp from aiohttp import web +from s2python.common import ControlType from flexmeasures_client.client import FlexMeasuresClient from flexmeasures_client.s2.cem import CEM from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple -from flexmeasures_client.s2.python_s2_protocol.common.schemas import ControlType + +# from flexmeasures_client.s2.python_s2_protocol.common.schemas import ControlType async def rm_details_watchdog(ws, cem: CEM): @@ -71,8 +73,10 @@ async def websocket_handler(request): fm_client = FlexMeasuresClient("toy-password", "toy-user@flexmeasures.io") - cem = CEM(sensor_id=1, fm_client=fm_client) - frbc = FRBCSimple(power_sensor_id=1, price_sensor_id=2) + cem = CEM(fm_client=fm_client) + frbc = FRBCSimple( + power_sensor_id=1, price_sensor_id=2, soc_sensor_id=3, rm_discharge_sensor_id=4 + ) cem.register_control_type(frbc) # create "parallel" tasks for the message producer and consumer diff --git a/tests/test_client.py b/tests/test_client.py index d7f774db..4d7dd24d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -495,6 +495,22 @@ async def test_trigger_and_get_schedule() -> None: assert schedule["values"] == [2.15, 3, 2] +@pytest.mark.asyncio +async def test_get_account() -> None: + with aioresponses() as m: + m.get( + "http://localhost:5000/api/v3_0/accounts", + status=200, + payload=[{"name": "Toy Account"}], + ) + flexmeasures_client = FlexMeasuresClient( + email="toy-user@flexmeasures.io", + password="toy-password", + ) + account = await flexmeasures_client.get_account() + assert account["name"] == "Toy Account" + + @pytest.mark.asyncio async def test_get_sensor_data() -> None: with aioresponses() as m: diff --git a/tests/test_s2_coordinator.py b/tests/test_s2_coordinator.py index 9903e4ac..cfb1c0f0 100644 --- a/tests/test_s2_coordinator.py +++ b/tests/test_s2_coordinator.py @@ -33,7 +33,7 @@ @pytest.mark.asyncio async def test_cem(): # TODO: move into different test functions - cem = CEM(fm_client=None) + cem = CEM(sensor_id=1, fm_client=None) frbc = FRBCTest() cem.register_control_type(frbc) From a5a35a88354e91c9dda3f8559ebc4ac300ce4753 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Dec 2024 21:50:28 +0100 Subject: [PATCH 3/7] fix: mock user authentication by setting a dummy access_token Signed-off-by: F.N. Claessen --- tests/test_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_client.py b/tests/test_client.py index 4d7dd24d..766dc7fe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -507,6 +507,7 @@ async def test_get_account() -> None: email="toy-user@flexmeasures.io", password="toy-password", ) + flexmeasures_client.access_token = "test-token" account = await flexmeasures_client.get_account() assert account["name"] == "Toy Account" From 48f6a8a8012274e0885a0cbac40123a01bc44db8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Dec 2024 22:11:25 +0100 Subject: [PATCH 4/7] feat: lacking a dedicated API endpoint, we fetch all accessible users first, then pick out ourselves, locate our account ID, and then fetch our organisation account info (two API calls needed, I'm afraid) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 36 +++++++++++++-------------- tests/test_client.py | 41 ++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index c3f403c7..6fd024fc 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -322,31 +322,31 @@ async def get_schedule( ) return schedule - async def get_account(self) -> list[dict]: - """Get the account of the current user. + async def get_account(self) -> dict | None: + """Get the organisation account of the current user. - :returns: account as dictionary, for example: + :returns: organisation account as dictionary, for example: { - 'id': 1, - 'name': 'FlexMeasures', - + "id": 1, + "name": "Positive Design", } """ - account_data, status = await self.request(uri="accounts", method="GET") + users, status = await self.request(uri="users", method="GET") check_for_status(status, 200) - # Return just the 'account_roles','id' and 'name' fields - account_data = [ - { - "account_roles": account["account_roles"], - "id": account["id"], - "name": account["name"], - } - for account in account_data - ] - - return account_data + account_id = None + for user in users: + if user["email"] == self.email: + account_id = user["account_id"] + if account_id is None: + raise NotImplementedError(f"User does not seem to belong to account, which should not be possible.") + account, status = await self.request( + uri=f"accounts/{account_id}", + method="GET", + ) + check_for_status(status, 200) + return account async def get_assets(self) -> list[dict]: """Get all the assets available to the current user. diff --git a/tests/test_client.py b/tests/test_client.py index 766dc7fe..7853e3e9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -499,17 +499,50 @@ async def test_trigger_and_get_schedule() -> None: async def test_get_account() -> None: with aioresponses() as m: m.get( - "http://localhost:5000/api/v3_0/accounts", + "http://localhost:5000/api/v3_0/users", status=200, - payload=[{"name": "Toy Account"}], + payload=[ + { + "account_id": 1, + "active": True, + "email": "toy-user@flexmeasures.io", + "id": 39, + "username": "toy-user", + }, + { + "account_id": 1, + "active": True, + "email": "toy-colleague@flexmeasures.io", + "id": 40, + "username": "toy-colleague", + }, + { + "account_id": 2, + "active": True, + "email": "toy-client@flexmeasures.io", + "id": 41, + "username": "toy-client", + }, + ] + ) + m.get( + "http://localhost:5000/api/v3_0/accounts/1", + status=200, + payload={ + "id": 1, + "name": "Positive Design", + } ) flexmeasures_client = FlexMeasuresClient( + host="localhost", + port=5000, email="toy-user@flexmeasures.io", password="toy-password", ) flexmeasures_client.access_token = "test-token" - account = await flexmeasures_client.get_account() - assert account["name"] == "Toy Account" + account = await flexmeasures_client.get_account() + assert account["id"] == 1 + assert account["name"] == "Positive Design" @pytest.mark.asyncio From 9dcf34eeb35bd9502cafc72b754f0a8b6b4d6eba Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Fri, 13 Dec 2024 14:48:45 +0100 Subject: [PATCH 5/7] PPBC --- src/flexmeasures_client/client.py | 10 +++- .../s2/control_types/PPBC/__init__.py | 4 ++ .../s2/control_types/PPBC/ppbc_simple.py | 52 +++++++++++++++++++ .../s2/control_types/PPBC/utils.py | 0 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/flexmeasures_client/s2/control_types/PPBC/__init__.py create mode 100644 src/flexmeasures_client/s2/control_types/PPBC/ppbc_simple.py create mode 100644 src/flexmeasures_client/s2/control_types/PPBC/utils.py diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 6fd024fc..956c1730 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -340,11 +340,19 @@ async def get_account(self) -> dict | None: if user["email"] == self.email: account_id = user["account_id"] if account_id is None: - raise NotImplementedError(f"User does not seem to belong to account, which should not be possible.") + raise NotImplementedError( + "User does not seem to belong to account, which should not be possible." + ) + # Force account to be a dictionary + account, status = await self.request( uri=f"accounts/{account_id}", method="GET", ) + if not isinstance(account, dict): + raise ContentTypeError( + f"Expected an account dictionary, but got {type(account)}", + ) check_for_status(status, 200) return account diff --git a/src/flexmeasures_client/s2/control_types/PPBC/__init__.py b/src/flexmeasures_client/s2/control_types/PPBC/__init__.py new file mode 100644 index 00000000..2c989303 --- /dev/null +++ b/src/flexmeasures_client/s2/control_types/PPBC/__init__.py @@ -0,0 +1,4 @@ +# from s2python.ppbc import PPBCScheduleInstruction + +# from flexmeasures_client.s2.control_types import ControlTypeHandler +# from flexmeasures_client.s2.utils import get_reception_status, get_unique_id diff --git a/src/flexmeasures_client/s2/control_types/PPBC/ppbc_simple.py b/src/flexmeasures_client/s2/control_types/PPBC/ppbc_simple.py new file mode 100644 index 00000000..2e98863c --- /dev/null +++ b/src/flexmeasures_client/s2/control_types/PPBC/ppbc_simple.py @@ -0,0 +1,52 @@ +""" +This module contains the PPBC simple control type. +""" + +from datetime import datetime, timedelta + +import pytz +from s2python.ppbc import PPBCScheduleInstruction + +from flexmeasures_client.s2.control_types.PPBC import PPBC + + +class PPBCSimple(PPBC): + _power_sensor_id: int + _price_sensor_id: int + _schedule_duration: timedelta + _valid_from_shift: timedelta + + def __init__( + self, + power_sensor_id: int, + price_sensor_id: int, + timezone: str = "UTC", + schedule_duration: timedelta = timedelta(hours=12), + max_size: int = 100, + valid_from_shift: timedelta = timedelta(days=1), + ) -> None: + super().__init__(max_size) + self._power_sensor_id = power_sensor_id + self._price_sensor_id = price_sensor_id + self._schedule_duration = schedule_duration + self._timezone = pytz.timezone(timezone) + + # delay the start of the schedule from the time `valid_from` + # of the PPBC.SystemDescritption. + self._valid_from_shift = valid_from_shift + + def now(self): + return self._timezone.localize(datetime.now()) + + async def send_schedule_instruction(self, instruction: PPBCScheduleInstruction): + await self._fm_client.post_schedule( + self._power_sensor_id, + start=self.now(), + values=instruction.power_values, + unit="MW", + duration=self._schedule_duration, + price_sensor_id=self._price_sensor_id, + price_values=instruction.price_values, + price_unit="EUR/MWh", + valid_from=self.now() + self._valid_from_shift, + ) diff --git a/src/flexmeasures_client/s2/control_types/PPBC/utils.py b/src/flexmeasures_client/s2/control_types/PPBC/utils.py new file mode 100644 index 00000000..e69de29b From 562eb1604af0165b7c9d66f25a3578b631148d75 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Fri, 13 Dec 2024 14:55:17 +0100 Subject: [PATCH 6/7] applied black formatting --- src/flexmeasures_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 956c1730..c2b6dd0e 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -351,7 +351,7 @@ async def get_account(self) -> dict | None: ) if not isinstance(account, dict): raise ContentTypeError( - f"Expected an account dictionary, but got {type(account)}", + f"Expected an account dictionary! but got {type(account)}", ) check_for_status(status, 200) return account From 93839e73a17aee9719809e53adb4e63e71295ddd Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Fri, 13 Dec 2024 14:57:33 +0100 Subject: [PATCH 7/7] applied black formatting test_client.py --- tests/test_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 7853e3e9..6ffbf487 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -523,7 +523,7 @@ async def test_get_account() -> None: "id": 41, "username": "toy-client", }, - ] + ], ) m.get( "http://localhost:5000/api/v3_0/accounts/1", @@ -531,7 +531,7 @@ async def test_get_account() -> None: payload={ "id": 1, "name": "Positive Design", - } + }, ) flexmeasures_client = FlexMeasuresClient( host="localhost",