Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
733ef40
refactor: migrate from tox/PyScaffold to uv for dependency management…
Feb 19, 2026
d661056
fix: fix whitespace issues
Feb 23, 2026
cef07f3
feat: move from pre-commit git repos to using uv for pre-commit scripts
Feb 23, 2026
d63afe5
dev: try using the _sending_queue instead
Flix6x Feb 25, 2026
564b8f1
Revert "dev: try using the _sending_queue instead"
Flix6x Feb 25, 2026
2db7a4f
fix: give the sending task enough time to flush the queue before the …
Flix6x Feb 25, 2026
35cc9ad
dev: use DEBUG logging level
Flix6x Feb 25, 2026
375b49c
feat: await message sent rather than message queued
Flix6x Feb 25, 2026
14f7792
fix: also close WS if closing CEM
Flix6x Feb 25, 2026
4799585
feat: skip setting up the toy account
Flix6x Feb 25, 2026
89d4338
feat: post prices in background task
Flix6x Feb 25, 2026
60ff6af
Revert "feat: skip setting up the toy account"
Flix6x Feb 25, 2026
404ed6b
chore: use modern method name
Flix6x Feb 25, 2026
5d4de10
fix: fall back on now in case FRBCActuatorStatus.transition_timestamp…
Flix6x Feb 25, 2026
f8ea658
fix: actuator status unit
Flix6x Feb 25, 2026
d59f340
feat: skip setting up the toy account
Flix6x Feb 25, 2026
6abd2a1
fix: copy-paste mistake
Flix6x Feb 25, 2026
5328de9
fix: discharge unit
Flix6x Feb 25, 2026
eae232b
docs: update instruction to run docker-compose stack
Flix6x Feb 25, 2026
0e72c25
Revert "fix: discharge unit"
Flix6x Feb 25, 2026
abb0a74
fix: schedule power sensor instead of dimensionless discharge sensor
Flix6x Feb 25, 2026
bbaac73
feat: trigger schedule with each storage status (not yet rate limited…
Flix6x Feb 25, 2026
374e44a
feat: post 1 year of data in background tasks
Flix6x Feb 25, 2026
a1d6ba7
Revert "feat: post 1 year of data in background tasks"
Flix6x Feb 25, 2026
f8e0d62
fix: set prior knowledge of prices and test with now
Flix6x Feb 25, 2026
be6d8d3
fix: floor the schedule start
Flix6x Feb 25, 2026
5334c0c
fix: messages should now be routed through cem.send_message
Flix6x Feb 25, 2026
2845036
fix: messages should now be routed through cem.send_message; update h…
Flix6x Feb 25, 2026
e01176d
feat: roll 3 days of test prices
Flix6x Feb 25, 2026
c780f98
style: black, isort
Flix6x Feb 25, 2026
26f4778
style: black
Flix6x Feb 25, 2026
c691cf2
Merge remote-tracking branch 'origin/main' into dev/fix-handshake-han…
Flix6x Feb 26, 2026
5fb3a26
feat: make schedules appear with consumption on the positive axis
Flix6x Feb 26, 2026
508611b
fix: update _sending_queue.put to send_message in FillRateBasedContro…
Flix6x Feb 26, 2026
07b55c5
feat: port send_fill_level_target_profile
Flix6x Feb 27, 2026
872224c
feat: save scheduled state-of-charge, too
Flix6x Feb 27, 2026
0b96cc5
feat: set sensors_to_show on CEM asset
Flix6x Feb 27, 2026
5ef6f64
fix: type annotation
Flix6x Feb 27, 2026
010d584
fix: flex-model soc-unit
Flix6x Feb 27, 2026
c389510
fix: get rid of valid_from_shift
Flix6x Feb 27, 2026
e5ad8a0
dev: log used SystemDescription
Flix6x Feb 27, 2026
54dd188
style: black
Flix6x Feb 27, 2026
2819e4f
feat: port send_usage_forecast
Flix6x Feb 27, 2026
6cf71ad
feat: relax constraints
Flix6x Feb 27, 2026
b78fbc8
feat: we already remove scheduled power values that are not a change …
Flix6x Feb 27, 2026
baac3bb
dev: debug log JSON instructions
Flix6x Feb 27, 2026
0577d53
dev: speed up polling for simulations
Flix6x Feb 27, 2026
b99b39b
docs: add instruction to create an admin user
Flix6x Feb 27, 2026
7ce4c35
refactor: rename variable
Flix6x Feb 27, 2026
806dae0
style: isort
Flix6x Feb 27, 2026
5be4b64
fix: mypy
Flix6x Feb 27, 2026
abb68e0
chore: resolve implicit todo
Flix6x Feb 27, 2026
59108c0
fix: obvious typo
Flix6x Feb 27, 2026
92fd551
fix: debug log instead of error log
Flix6x Feb 27, 2026
1b8d4c9
feat: port send_leakage_behaviour
Flix6x Feb 27, 2026
fe02d33
feat: CEM supports setting simulation time, by wrapping the S2 messag…
Flix6x Mar 2, 2026
fd91a62
style: isort, flake8
Flix6x Mar 2, 2026
e8e6006
feat: record data in FlexMeasures as if it was recorded at simulation…
Flix6x Mar 2, 2026
14bf5b8
style: silence mypy on overwriting a method with a lambda function
Flix6x Mar 2, 2026
7e6b29a
feat: force new scheduling job creation when using a prior
Flix6x Mar 9, 2026
a89a41e
fix: upgrade timely-beliefs to fix scheduler bug with resampling from…
Flix6x Mar 9, 2026
31bddec
fix: pass prior to trigger_and_get_schedule
Flix6x Mar 10, 2026
906a7f5
fix: start schedule from the time of the most recent storage status
Flix6x Mar 10, 2026
f61fd60
style: black
Flix6x Mar 10, 2026
2d5b0fe
fix: still make sure to run `flexmeasures add toy-account` once
Flix6x Mar 10, 2026
eda6b28
feat: add ability to customize fill_level_scale
Flix6x Mar 10, 2026
1abdd71
dev: remove todo (soc-at-start is now actually coming from the latest…
Flix6x Mar 10, 2026
9f20ab4
feat: derive consumption-capacity and production-capacity from operat…
Flix6x Mar 10, 2026
ea68aa0
docs: update docstring
Flix6x Mar 10, 2026
605980a
feat: move to J and W as default energy unit and power unit, respecti…
Flix6x Mar 10, 2026
c2bab5d
fix: wrong conversion
Flix6x Mar 10, 2026
00f363a
docs: clarify inline note
Flix6x Mar 10, 2026
80b2f61
refactor: prepare for more params
Flix6x Mar 10, 2026
2cc3b75
feat: support getting a schedule in a given unit
Flix6x Mar 10, 2026
3a547b1
feat: require minimum version for getting a schedule in a given unit
Flix6x Mar 10, 2026
d29e64c
fix: get schedule in the assumed power unit
Flix6x Mar 10, 2026
fca2c5b
feat: note the current FM server version
Flix6x Mar 10, 2026
fb6d947
fix: actually requires v0.32.0
Flix6x Mar 10, 2026
7d4c0c0
docs: clarify that the server ignores the parameter, not the client
Flix6x Mar 10, 2026
cc28d77
style: black
Flix6x Mar 10, 2026
0760825
fix: mistake while refactoring (or from running black?)
Flix6x Mar 10, 2026
67cbd0b
fix: misinterpreted the usage forecast scale
Flix6x Mar 11, 2026
6ba2337
fix: CEM should only relax soc-constraints (not capacity-constraints …
Flix6x Mar 11, 2026
2711aff
fix: stop flipping the values from the actuator status (now that we u…
Flix6x Mar 11, 2026
fbe2461
feat: use local flexmeasures repo in server and worker
Flix6x Mar 12, 2026
9a2b131
Merge remote-tracking branch 'origin/main' into refactor/uv-migration
Flix6x Mar 13, 2026
a273788
fix: release step was split off to separate release.yml
Flix6x Mar 13, 2026
d4a72fe
feat: update github workflows. Clean up .pre-commit-config.yaml.
Mar 16, 2026
06406c9
fix: fix s2 tests in CI. Use uv for s2 tests.
Mar 16, 2026
274bbc6
feat: use uv for readthedocs
Mar 16, 2026
0de29b8
refactor: remove coverage for s2. Add coverage to general test
Mar 16, 2026
cd7b732
fix: fix issues in S2 client and server examples. Use Pydantic V2 met…
Mar 18, 2026
1ec5a87
feat: update docs to use new UV setup
Mar 18, 2026
3afc4af
fix: fix docker compose CEM not connecting to flexmeasures server. Fi…
Mar 18, 2026
db0c676
feat: rewrite CEM docs to use new UV setup and new docker compose set…
Mar 18, 2026
030c6bf
fix: trigger_schedule retreiving outdated schedule
Mar 18, 2026
b78bacf
dev: expose dev-db on local port
Flix6x Apr 13, 2026
5664e2c
Merge branch 'refactor/uv-migration' into dev/fix-handshake-handler
Flix6x Apr 13, 2026
0e4489a
fix: missing import
Flix6x Apr 13, 2026
f3114cc
dev: fix db port exposure
Flix6x Apr 13, 2026
da7268a
dev: expose queue-db port, too
Flix6x Apr 13, 2026
21d28a1
fix: install missing packages for CEM
Flix6x Apr 13, 2026
78b87d5
feat: add charging-efficiency sensor
Flix6x Apr 13, 2026
5e2e4ce
refactor: rename variable
Flix6x Apr 13, 2026
72321d4
docs: update developer note
Flix6x Apr 13, 2026
7a87b91
feat: add production price sensor
Flix6x Apr 14, 2026
e700c22
fix: get existing charging_efficiency_sensor
Flix6x Apr 14, 2026
1af2a1d
Merge remote-tracking branch 'origin/main' into refactor/uv-migration
Flix6x Apr 14, 2026
642bd45
Merge remote-tracking branch 'stijn/refactor/uv-migration' into dev/f…
Flix6x Apr 14, 2026
fa88ad7
Merge remote-tracking branch 'origin/main' into dev/fix-handshake-han…
Flix6x Apr 14, 2026
347268d
fix: repeated keyword (prior comes from start, not now)
Flix6x Apr 14, 2026
655ba10
fix: use new internal method
Flix6x Apr 14, 2026
74fc885
dev: allow using dev server version
Flix6x Apr 14, 2026
a5b382f
fix: parse two more JSON fields
Flix6x Apr 14, 2026
5f62c41
feat: add chart with power values
Flix6x Apr 14, 2026
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
75 changes: 75 additions & 0 deletions docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ------------------------------------------------------------------
# This allow to run the S2 CEM from your local FlexMeasures Client code in a docker compose stack.
# Assuming you have flexmeasures the repo next to your flexmeasures-client repo,
# run this from the flexmeasures folder (which contains the Dockerfile):
# docker compose \
# -f docker-compose.yml \
# -f ../flexmeasures-client/docker-compose.override.yml \
# up
# ------------------------------------------------------------------

services:
dev-db:
ports:
- "5433:5432"
queue-db:
ports:
- "6380:6379"
server:
volumes:
# A place for config and plugin code, and custom requirements.txt
# The 1st mount point is for running the FlexMeasures CLI, the 2nd for gunicorn
# We use :rw so flexmeasures CLI commands can write log files
- ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw
- ./flexmeasures-instance/:/app/instance/:rw
- ../flexmeasures/flexmeasures:/app/flexmeasures:rw
command:
- |
pip install --break-system-packages -e /app
pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt
pip install timely-beliefs -U --break-system-packages
flexmeasures db upgrade
if ! flexmeasures show accounts | grep -q "Docker Toy Account"; then
flexmeasures add toy-account --name 'Docker Toy Account'
fi
gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application
worker:
volumes:
# a place for config and plugin code, and custom requirements.txt
- ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw
- ../flexmeasures/flexmeasures:/app/flexmeasures:rw
- ./flexmeasures-instance/:/app/instance/:rw
command:
- |
pip install --break-system-packages -e /app
pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt
pip install timely-beliefs -U --break-system-packages
flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling
cem:
build:
context: .
dockerfile: Dockerfile
depends_on:
- server
restart: always
ports:
- "8080:8080" # aiohttp default; adjust if you change it
environment:
# Optional, but useful if you later make this configurable
FLEXMEASURES_BASE_URL: http://server:5000
FLEXMEASURES_USER: toy-user@flexmeasures.io
FLEXMEASURES_PASSWORD: toy-password
LOGGING_LEVEL: DEBUG
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_FLEXMEASURES_CLIENT: "0.0.0"
volumes:
# If flexmeasures_client lives in your repo and you want live edits
- ../flexmeasures-client:/app/flexmeasures-client:rw
entrypoint: ["/bin/sh", "-c"]
command:
- |
# pip install --break-system-packages --no-cache-dir "git+https://github.com/FlexMeasures/flexmeasures-client.git@main#egg=flexmeasures-client[s2]"
pip install --break-system-packages -e /app/flexmeasures-client[s2]
pip install --break-system-packages aiohttp
pip install --break-system-packages pytz
pip install --break-system-packages s2-python==0.8.2
python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py
27 changes: 27 additions & 0 deletions docs/CEM.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,39 @@ Then point your Resource Managers (RMs) to ``http://localhost:8080/ws`` and run:

uv run src/flexmeasures_client/s2/script/websockets_server.py

We also included a ``docker-compose.override.yaml`` that can be used to set up the CEM including the FlexMeasures server, creating a fully self-hosted HEMS.
Assuming your ``flexmeasures`` and ``flexmeasures-client`` repo folders are located side by side, run this from your flexmeasures folder:

.. code-block:: bash

docker compose \
-f docker-compose.yml \
-f ../flexmeasures-client/docker-compose.override.yml \
up


This creates the following containers for the CEM:

- a WebSocket server (FlexMeasures Client)
- web and worker servers (FlexMeasures)
- a database server (Postgres)
- a queue server (Redis)
- a mail server (MailHog)

To test, run the included example RM:

.. code-block:: bash

uv run src/flexmeasures_client/s2/script/websockets_client.py

For full access via the UI, create an admin user for the Docker Toy Account (here, we assume it has ID 1):

.. code-block:: bash

docker exec -it flexmeasures-server-1 bash
flexmeasures show accounts
flexmeasures add user --roles admin --account 1 --email <email> --username <username>

Disclaimer
==========

Expand Down
26 changes: 18 additions & 8 deletions src/flexmeasures_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ def _parse_asset_json_fields(asset: dict) -> None:
_parse_json_field(asset, "attributes")
_parse_json_field(asset, "flex_context")
_parse_json_field(asset, "flex_model")
_parse_json_field(asset, "sensors_to_show")
_parse_json_field(asset, "kpi_sensors_to_show")


def _parse_sensor_json_fields(sensor: dict) -> None:
Expand Down Expand Up @@ -619,6 +621,7 @@ async def get_schedule(
sensor_id: int,
schedule_id: str,
duration: str | timedelta | None = None,
unit: str | None = None,
) -> dict:
"""Get schedule with given ID.

Expand All @@ -630,12 +633,14 @@ async def get_schedule(
'unit': 'MW'
}
"""
params = {}
if duration is not None:
params = {
"duration": pd.Timedelta(duration).isoformat(), # for example: PT1H
}
else:
params = {}
params["duration"] = pd.Timedelta(duration).isoformat() # for example: PT1H
if unit is not None:
message = "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. "
f"This parameter will be ignored by the server, which is at version {self.server_version}."
await self.ensure_minimum_server_version("0.31.0.dev90", message)
params["unit"] = unit
schedule, status = await self.request(
uri=f"sensors/{sensor_id}/schedules/{schedule_id}",
method="GET",
Expand Down Expand Up @@ -770,7 +775,7 @@ async def get_assets(
) < Version("0.31.0"):
self.logger.warning(
"get_assets(): The 'root', 'depth' and 'fields' parameters require FlexMeasures server version 0.31.0 or above. "
"These parameters will be ignored."
f"These parameters will be ignored for server version {self.server_version}."
)
if root and isinstance(root, int):
uri += f"&root={root}"
Expand Down Expand Up @@ -855,6 +860,7 @@ async def trigger_and_get_schedule(
asset_id: int | None = None,
prior: datetime | None = None,
scheduler: str | None = None,
unit: str | None = None,
) -> dict | list[dict]:
"""Trigger a schedule and then fetch it.

Expand Down Expand Up @@ -890,7 +896,10 @@ async def trigger_and_get_schedule(
if sensor_id is not None:
# Get the schedule for a single device
return await self.get_schedule(
sensor_id=sensor_id, schedule_id=schedule_id, duration=duration
sensor_id=sensor_id,
schedule_id=schedule_id,
duration=duration,
unit=unit,
)
elif flex_model is None:
# If there is no flex-model referencing power sensors, no power schedules are retrieved
Expand Down Expand Up @@ -1140,7 +1149,7 @@ async def update_asset(self, asset_id: int, updates: dict) -> dict:
) < Version("0.31.0"):
self.logger.warning(
"update_asset(): The 'aggregate-power' flex-context field requires FlexMeasures server version 0.31.0 or above. "
"The 'aggregate-power' field will be ignored by the server."
f"The 'aggregate-power' field will be ignored by the server, which is at version {self.server_version}."
)
updates["flex_context"] = json.dumps(updates["flex_context"])
if "flex_model" in updates:
Expand Down Expand Up @@ -1253,6 +1262,7 @@ async def trigger_schedule(

if prior is not None:
message["prior"] = pd.Timestamp(prior).isoformat()
message["force_new_job_creation"] = True
if scheduler is not None:
if asset_id is None:
raise ValueError(
Expand Down
49 changes: 36 additions & 13 deletions src/flexmeasures_client/s2/cem.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json
import logging
import math
from asyncio import Queue
import asyncio
from collections import defaultdict
from datetime import datetime, timedelta
from logging import Logger
Expand Down Expand Up @@ -58,7 +58,7 @@ class CEM(Handler):
] # maps the CommodityQuantity power measurement sensors to FM sensor IDs

_fm_client: FlexMeasuresClient
_sending_queue: Queue[pydantic.BaseModel]
_sending_queue: asyncio.Queue[tuple[pydantic.BaseModel, asyncio.Future]]

_timers: dict[str, datetime]
_datastore: dict
Expand All @@ -84,7 +84,7 @@ def __init__(
super(CEM, self).__init__()

self._fm_client = fm_client
self._sending_queue = Queue()
self._sending_queue = asyncio.Queue()
self._power_sensors = dict()
self.power_sensor_id = power_sensor_id
self._control_types_handlers = dict()
Expand Down Expand Up @@ -158,8 +158,8 @@ def register_control_type(self, control_type_handler: ControlTypeHandler):
# add fm_client to control_type handler
control_type_handler._fm_client = self._fm_client

# add sending queue
control_type_handler._sending_queue = self._sending_queue
# add send_message method so the handler can send messages
control_type_handler.send_message = self.send_message

# Add logger
control_type_handler._logger = self._logger
Expand All @@ -184,7 +184,19 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str):
if isinstance(message, str):
message = json.loads(message)

self._logger.debug(f"Received: {message}")
# Detect wrapper
if isinstance(message, dict) and "message" in message and "metadata" in message:
metadata = message["metadata"]
message = message["message"]
self._logger.debug("Received wrapped message")
self._logger.debug(f"Received message: {message}")
self._logger.debug(f"Received metadata: {metadata}")
if "dt" in metadata:
for control_type in self._control_types_handlers.values():
control_type.now = lambda: metadata["dt"] # type: ignore
self.now = lambda: metadata["dt"] # type: ignore
else:
self._logger.debug(f"Received: {message}")

# try to handle the message with the control_type handle
if (
Expand Down Expand Up @@ -215,7 +227,7 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str):
)

if response is not None:
await self._sending_queue.put(response)
await self.send_message(response)

def update_control_type(self, control_type: ControlType):
"""
Expand All @@ -224,15 +236,24 @@ def update_control_type(self, control_type: ControlType):
"""
self._control_type = control_type

async def get_message(self) -> str:
async def get_message(self) -> tuple[str, asyncio.Future]:
"""Call this function to get the messages to be sent to the RM

Returns:
str: message in JSON format
"""

message = await self._sending_queue.get()
return message.model_dump(mode="json")
item = await self._sending_queue.get()

if not isinstance(item, tuple) or len(item) != 2:
raise RuntimeError(
"Invalid item in sending queue. All messages must go through send_message() rather than _sending_queue.put()."
)

message, fut = item
message = message.model_dump(mode="json")

return message, fut

async def activate_control_type(
self, control_type: ControlType
Expand Down Expand Up @@ -272,8 +293,7 @@ async def activate_control_type(
].register_success_callbacks(
message_id, self.update_control_type, control_type=control_type
)

await self._sending_queue.put(
await self.send_message(
SelectControlType(message_id=message_id, control_type=control_type)
)
return None
Expand Down Expand Up @@ -418,8 +438,11 @@ def handle_revoke_object(self, message: RevokeObject):
return get_reception_status(message, ReceptionStatusValues.OK)

async def send_message(self, message):
loop = asyncio.get_running_loop()
fut = loop.create_future()
self._logger.debug(f"Sent: {message}")
await self._sending_queue.put(message)
await self._sending_queue.put((message, fut))
await fut # wait until actually sent


def get_commodity_unit(commodity_quantity) -> str:
Expand Down
2 changes: 1 addition & 1 deletion src/flexmeasures_client/s2/control_types/FRBC/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,4 @@ async def trigger_schedule(self, system_description_id: str):
)

# put instruction into the sending queue
await self._sending_queue.put(instruction)
await self.send_message(instruction)
Loading
Loading