Skip to content
Draft
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
19 changes: 18 additions & 1 deletion documentation/api/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,24 @@ 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-31 | 2026-04-28
v3.0-32 | 2026-05-xx
""""""""""""""""""""

- Introduced explicit ``inflexible-loads`` and ``inflexible-generators`` fields in the ``flex-context``, replacing the ambiguous ``inflexible-device-sensors`` field:

- ``inflexible-loads``: list of sensor IDs for inflexible consumers; sensor values are interpreted using the consumption-is-positive sign convention.
- ``inflexible-generators``: list of sensor IDs for inflexible generators; sensor values are interpreted using the production-is-positive sign convention (the FlexMeasures default).
- ``inflexible-device-sensors`` is deprecated and will be removed in a future version.
Use ``inflexible-loads`` for sensors with the consumption-is-positive convention and ``inflexible-generators`` for sensors with the production-is-positive convention.

- Introduced explicit ``consumption`` and ``production`` fields in the per-sensor entry of the ``flex-model`` list (for asset-level scheduling), replacing the ambiguous ``sensor`` field:

- ``consumption``: sensor ID for the flexible consumer being scheduled; the scheduler applies the consumption-is-positive sign convention.
- ``production``: sensor ID for the flexible producer being scheduled; the scheduler applies the production-is-positive sign convention.
- ``sensor`` is deprecated for asset-level scheduling. Use ``consumption`` or ``production`` instead for unambiguous sign conventions.
The ``sensor`` fallback still works and falls back on the sensor's ``consumption_is_positive`` attribute to determine the sign convention.


""""""""""""""""""""

- Added a unified job status endpoint ``GET /api/v3_0/jobs/<uuid>`` that returns the current execution status and a human-readable result message for any background job (scheduling, forecasting, etc.) identified by its UUID.
Expand Down
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ v0.33.0 | May XX, 2026

New features
-------------
* Make flex-config more explicit about production vs consumption: replace ``inflexible-device-sensors`` in the flex-context with ``inflexible-loads`` and ``inflexible-generators``, and replace ``sensor`` in the asset-level flex-model with ``consumption`` or ``production`` [see `PR #XXXX <https://www.github.com/FlexMeasures/flexmeasures/pull/XXXX>`_]
* Added API and UI support for copying assets and their subtrees [see `PR #2017 <https://www.github.com/FlexMeasures/flexmeasures/pull/2017>`_ and `PR #2120 <https://www.github.com/FlexMeasures/flexmeasures/pull/2120>`_]
* Improve UX after deleting a child asset through the UI [see `PR #2119 <https://www.github.com/FlexMeasures/flexmeasures/pull/2119>`_]
* Improve source filtering in the sensor data GET endpoint by exposing the documented query parameters in Swagger and allowing filtering by the account linked to data sources [see `PR #2083 <https://www.github.com/FlexMeasures/flexmeasures/pull/2083>`_]
Expand Down
23 changes: 21 additions & 2 deletions documentation/concepts/data-model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,29 @@ We assume that this is what users send in.

Note that, if forecasts are created, they will have the same sign as original data.

For schedules, the sign of resulting power data (beliefs) is being switched when data is stored (assuming consumption , and you can prevent that by setting ``sensor.attributes["consumption_is_positive"] = True``.
For schedules, the sign convention of resulting power data (beliefs) depends on how the sensor is configured:

- By default, FlexMeasures stores schedule results with **production as positive** and consumption as negative (the FlexMeasures default).
- If you set ``sensor.attributes["consumption_is_positive"] = True`` on the power sensor, consumption is stored as positive.

.. note:: We will soon document better what the scheduler does in detail, and how the attribute works.
When it comes to inflexible devices in the :ref:`flex_context <flex_context>`, the convention should be made explicit using the new ``inflexible-loads`` and ``inflexible-generators`` fields:

- Use ``inflexible-generators`` for sensors that store **production as positive** values (e.g. rooftop solar where positive readings = generation). This is also the FlexMeasures default.
- Use ``inflexible-loads`` for sensors that store **consumption as positive** values (e.g. a building load sensor where positive readings = consumption).

For example, if your solar PV sensor uses the default FlexMeasures convention (positive = production), include it as::

"flex-context": {"inflexible-generators": [<solar_sensor_id>]}

If your building consumption sensor stores positive values for load, include it as::

"flex-context": {"inflexible-loads": [<building_sensor_id>]}

When triggering asset-level schedules, use ``consumption`` or ``production`` in the flex-model to make the sign convention explicit::

"flex-model": [{"production": <solar_sensor_id>}, {"consumption": <battery_sensor_id>, "soc-at-start": "50 kWh"}]

This replaces the ambiguous ``sensor`` key and avoids relying on the ``consumption_is_positive`` sensor attribute for scheduling purposes.


Accounts & Users
Expand Down
11 changes: 10 additions & 1 deletion documentation/features/scheduling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul
* - Field
- Example value
- Description
* - ``inflexible-device-sensors``
* - ``inflexible-loads``
- |INFLEXIBLE_LOADS.example|
- .. include:: ../_autodoc/INFLEXIBLE_LOADS.rst
* - ``inflexible-generators``
- |INFLEXIBLE_GENERATORS.example|
- .. include:: ../_autodoc/INFLEXIBLE_GENERATORS.rst
* - ``inflexible-device-sensors`` *(deprecated)*
- |INFLEXIBLE_DEVICE_SENSORS.example|
- .. include:: ../_autodoc/INFLEXIBLE_DEVICE_SENSORS.rst
* - ``aggregate-power``
Expand Down Expand Up @@ -120,6 +126,9 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul

.. [#old_production_price_field] This field replaced the ``production-price-sensor`` field, which only accepted an integer (sensor ID).

.. [#old_inflexible_device_sensors_field] These fields replace the ``inflexible-device-sensors`` field, which did not distinguish between loads and generators.
Use ``inflexible-loads`` for sensors recording consumption (consumption-is-positive convention) and ``inflexible-generators`` for sensors recording production (production-is-positive convention, which is the FlexMeasures default).

.. [#asymmetric] ``site-consumption-capacity`` and ``site-production-capacity`` allow defining asymmetric contracted transport capacities for each direction (i.e. production and consumption).

.. [#minimum_capacity_overlap] In case this capacity field defines partially overlapping time periods, the minimum value is selected. See :ref:`variable_quantities`.
Expand Down
2 changes: 1 addition & 1 deletion documentation/tut/scripts/run-tutorial2-in-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ docker exec -it flexmeasures-server-1 flexmeasures show beliefs --sensor 3 --sta
echo "[TUTORIAL-RUNNER] update schedule taking solar into account ..."
docker exec -it flexmeasures-server-1 flexmeasures add schedule --sensor 2 \
--start ${TOMORROW}T07:00+01:00 --duration PT12H --soc-at-start 50% \
--flex-context '{"inflexible-device-sensors": [3]}' \
--flex-context '{"inflexible-generators": [3]}' \
--flex-model '{"soc-min": "50 kWh"}'

echo "[TUTORIAL-RUNNER] showing schedule ..."
Expand Down
4 changes: 2 additions & 2 deletions documentation/tut/scripts/run-tutorial3-in-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ docker exec -it flexmeasures-server-1 flexmeasures add beliefs --sensor 3 --sour
echo "[TUTORIAL-RUNNER] Now running both battery and PV together, still using block price profiles ..."
docker exec -it flexmeasures-server-1 flexmeasures add schedule --asset 2 \
--start ${TOMORROW}T07:00+01:00 --duration PT12H \
--flex-model '[{"sensor": 3, "consumption-capacity": "0 kW", "production-capacity": {"sensor": 3, "source": 4}}, {"sensor": 2, "soc-at-start": "225 kWh", "soc-min": "50 kWh"}]'\
--flex-model '[{"production": 3, "consumption-capacity": "0 kW", "production-capacity": {"sensor": 3, "source": 4}}, {"consumption": 2, "soc-at-start": "225 kWh", "soc-min": "50 kWh"}]'\
--flex-context tutorial3-priceprofile-flex-context.json

echo "[TUTORIAL-RUNNER] showing PV and battery schedule ..."
Expand All @@ -47,7 +47,7 @@ docker exec -it flexmeasures-server-1 flexmeasures add beliefs --sensor 3 --sour
echo "[TUTORIAL-RUNNER] Now running both battery and PV together, with realistic DA prices and larger battery ..."
docker exec -it flexmeasures-server-1 flexmeasures add schedule --asset 2 \
--start ${TOMORROW}T07:00+01:00 --duration PT12H \
--flex-model '[{"sensor": 3, "consumption-capacity": "0 kW", "production-capacity": {"sensor": 3, "source": 4}}, {"sensor": 2, "soc-at-start": "225 kWh", "soc-min": "50 kWh", "soc-max": "900kWh"}]'
--flex-model '[{"production": 3, "consumption-capacity": "0 kW", "production-capacity": {"sensor": 3, "source": 4}}, {"consumption": 2, "soc-at-start": "225 kWh", "soc-min": "50 kWh", "soc-max": "900kWh"}]'

echo "[TUTORIAL-RUNNER] showing PV and battery schedule ..."
docker exec -it flexmeasures-server-1 flexmeasures show beliefs --sensor 3 --sensor 2 --start ${TOMORROW}T07:00:00+01:00 --duration PT12H
8 changes: 4 additions & 4 deletions documentation/tut/toy-example-expanded.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ When solar production is high, less battery output can be send to the grid, as t

How does it work?

- We will tell FlexMeasures to take the solar production into account (using the ``inflexible-device-sensors`` flex-context field).
- We will tell FlexMeasures to take the solar production into account (using the ``inflexible-generators`` flex-context field).
- The battery's power capacity is not the limiting factor, but the `site-power-capacity` of the building (already a flex-context field, see :ref:`tut_toy_schedule`).
- The flows of the building's child assets are summed up on building level, and that constraint now will play a role.

Expand Down Expand Up @@ -96,7 +96,7 @@ This will have an effect on the available headroom for the battery, given the ``
--start ${TOMORROW}T07:00+01:00 \
--duration PT12H \
--soc-at-start 50% \
--flex-context '{"inflexible-device-sensors": [3]}'
--flex-context '{"inflexible-generators": [3]}'
--flex-model '{"soc-min": "50 kWh"}' \
New schedule is stored.

Expand All @@ -115,7 +115,7 @@ This will have an effect on the available headroom for the battery, given the ``
"soc-min": "50 kWh"
},
"flex-context": {
"inflexible-device-sensors": [3]
"inflexible-generators": [3]
}
}

Expand Down Expand Up @@ -149,7 +149,7 @@ This will have an effect on the available headroom for the battery, given the ``
"soc-min": "50 kWh",
},
flex_context={
"inflexible-device-sensors": [3], # solar production (sensor ID)
"inflexible-generators": [3], # solar production (sensor ID)
},
)
print(schedule)
Expand Down
10 changes: 5 additions & 5 deletions documentation/tut/toy-example-multiasset-curtailment.rst
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Note that we are still passing in the flex-context with block price profiles her
--asset 2 \
--start ${TOMORROW}T07:00+01:00 \
--duration PT12H \
--flex-model '[{"sensor": 3, "consumption-capacity": "0 kW", "production-capacity": {"sensor": 3, "source": 4}}, {"sensor": 2, "soc-at-start": "225 kWh", "soc-min": "50 kWh"}]'\
--flex-model '[{"production": 3, "consumption-capacity": "0 kW", "production-capacity": {"sensor": 3, "source": 4}}, {"consumption": 2, "soc-at-start": "225 kWh", "soc-min": "50 kWh"}]'\
--flex-context tutorial3-priceprofile-flex-context.json
New schedule is stored.

Expand All @@ -193,12 +193,12 @@ Note that we are still passing in the flex-context with block price profiles her
"duration": "PT12H",
"flex-model": [
{
"sensor": 3,
"production": 3,
"consumption-capacity": "0 kW",
"production-capacity": {"sensor": 3, "source": 4},
}
{
"sensor": 2,
"consumption": 2,
"soc-at-start": "225 kWh",
"soc-min": "50 kWh"
},
Expand Down Expand Up @@ -232,12 +232,12 @@ Note that we are still passing in the flex-context with block price profiles her
duration="PT12H",
flex_model=[
{
"sensor": 3, # solar production (sensor ID)
"production": 3, # solar production (sensor ID)
"consumption-capacity": "0 kW",
"production-capacity": {"sensor": 3, "source": 4},
},
{
"sensor": 2, # battery power (sensor ID)
"consumption": 2, # battery power (sensor ID)
"soc-at-start": "225 kWh",
"soc-min": "50 kWh",
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Migrate inflexible-device-sensors to inflexible-loads/generators, and sensor to consumption/production in flex-model

Revision ID: 9ed0e39b0447
Revises: f0ee99278f6f
Create Date: 2025-05-30 00:00:00.000000

"""

from __future__ import annotations

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB

# revision identifiers, used by Alembic.
revision = "9ed0e39b0447"
down_revision = "f0ee99278f6f"
branch_labels = None
depends_on = None


def _get_consumption_is_positive(connection, sensor_id: int) -> bool:
"""Look up a sensor's consumption_is_positive attribute (defaults to False)."""
row = connection.execute(
sa.text("SELECT attributes FROM sensor WHERE id = :sensor_id"),
{"sensor_id": sensor_id},
).fetchone()
if row is not None:
attributes = row[0] or {}
return attributes.get("consumption_is_positive", False)
# Sensor not found: default to production-positive (FlexMeasures default)
return False


def _migrate_flex_context(connection, asset_id: int, flex_context: dict) -> None:
"""Convert inflexible-device-sensors → inflexible-loads / inflexible-generators."""
old_sensor_ids = flex_context.get("inflexible-device-sensors", [])
if not old_sensor_ids:
return

loads = list(flex_context.get("inflexible-loads", []))
generators = list(flex_context.get("inflexible-generators", []))

for sensor_id in old_sensor_ids:
if _get_consumption_is_positive(connection, sensor_id):
loads.append(sensor_id)
else:
generators.append(sensor_id)

new_flex_context = dict(flex_context)
del new_flex_context["inflexible-device-sensors"]
if loads:
new_flex_context["inflexible-loads"] = loads
if generators:
new_flex_context["inflexible-generators"] = generators

connection.execute(
sa.text(
"UPDATE generic_asset SET flex_context = :flex_context WHERE id = :asset_id"
),
{"flex_context": sa.cast(new_flex_context, JSONB), "asset_id": asset_id},
)


def _migrate_flex_model(connection, asset_id: int, flex_model: list) -> None:
"""Convert sensor key → consumption or production in flex-model entries."""
changed = False
new_flex_model = []
for entry in flex_model:
if not isinstance(entry, dict) or "sensor" not in entry:
new_flex_model.append(entry)
continue
sensor_id = entry["sensor"]
new_entry = dict(entry)
del new_entry["sensor"]
if _get_consumption_is_positive(connection, sensor_id):
new_entry["consumption"] = sensor_id
else:
new_entry["production"] = sensor_id
new_flex_model.append(new_entry)
changed = True

if changed:
connection.execute(
sa.text(
"UPDATE generic_asset SET flex_model = :flex_model WHERE id = :asset_id"
),
{"flex_model": sa.cast(new_flex_model, JSONB), "asset_id": asset_id},
)


def upgrade():
"""
Migrate flex-context and flex-model on generic_asset:

1. flex-context: Convert `inflexible-device-sensors` list to `inflexible-loads` and
`inflexible-generators` based on each sensor's `consumption_is_positive` attribute.

2. flex-model: Convert entries with `sensor` key to either `consumption` or `production`
based on the sensor's `consumption_is_positive` attribute.
"""
connection = op.get_bind()

for asset_id, flex_context in connection.execute(
sa.text(
"SELECT id, flex_context FROM generic_asset "
"WHERE flex_context ? 'inflexible-device-sensors'"
)
).fetchall():
if flex_context:
_migrate_flex_context(connection, asset_id, flex_context)

for asset_id, flex_model in connection.execute(
sa.text(
"SELECT id, flex_model FROM generic_asset "
"WHERE flex_model IS NOT NULL AND jsonb_typeof(flex_model) = 'array'"
)
).fetchall():
if flex_model:
_migrate_flex_model(connection, asset_id, flex_model)


def _downgrade_flex_context(connection, asset_id: int, flex_context: dict) -> None:
"""Combine inflexible-loads + inflexible-generators back to inflexible-device-sensors."""
sensor_ids = list(flex_context.get("inflexible-loads", [])) + list(
flex_context.get("inflexible-generators", [])
)
new_flex_context = dict(flex_context)
new_flex_context.pop("inflexible-loads", None)
new_flex_context.pop("inflexible-generators", None)
if sensor_ids:
new_flex_context["inflexible-device-sensors"] = sensor_ids
connection.execute(
sa.text(
"UPDATE generic_asset SET flex_context = :flex_context WHERE id = :asset_id"
),
{"flex_context": sa.cast(new_flex_context, JSONB), "asset_id": asset_id},
)


def _downgrade_flex_model(connection, asset_id: int, flex_model: list) -> None:
"""Convert consumption/production back to sensor in flex-model entries."""
changed = False
new_flex_model = []
for entry in flex_model:
if not isinstance(entry, dict):
new_flex_model.append(entry)
continue
new_entry = dict(entry)
if "consumption" in new_entry:
new_entry["sensor"] = new_entry.pop("consumption")
changed = True
elif "production" in new_entry:
new_entry["sensor"] = new_entry.pop("production")
changed = True
new_flex_model.append(new_entry)
if changed:
connection.execute(
sa.text(
"UPDATE generic_asset SET flex_model = :flex_model WHERE id = :asset_id"
),
{"flex_model": sa.cast(new_flex_model, JSONB), "asset_id": asset_id},
)


def downgrade():
"""
Reverse migration: convert inflexible-loads/generators back to inflexible-device-sensors,
and consumption/production back to sensor in flex-model.
"""
connection = op.get_bind()

for asset_id, flex_context in connection.execute(
sa.text(
"SELECT id, flex_context FROM generic_asset "
"WHERE flex_context ? 'inflexible-loads' OR flex_context ? 'inflexible-generators'"
)
).fetchall():
if flex_context:
_downgrade_flex_context(connection, asset_id, flex_context)

for asset_id, flex_model in connection.execute(
sa.text(
"SELECT id, flex_model FROM generic_asset "
"WHERE flex_model IS NOT NULL AND jsonb_typeof(flex_model) = 'array'"
)
).fetchall():
if flex_model:
_downgrade_flex_model(connection, asset_id, flex_model)
Loading
Loading