Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f29ac55
refactor(ui): drive flex-context field types from UI_FLEX_CONTEXT_SCHEMA
Flix6x Jul 10, 2026
26640e2
feat(ui): edit per-commodity flex-contexts through a commodity tab ba…
Flix6x Jul 10, 2026
8845f4e
fix: namespace user commitment names with a custom: prefix
Flix6x Jul 10, 2026
9579ef8
refactor: derive UI flex-context field types from DBFlexContextSchema
Flix6x Jul 10, 2026
5c28c00
Merge branch 'feat/ui-flex-context-field-types' into feat/2230-ui-com…
Flix6x Jul 10, 2026
402bc32
merge: adopt derived UI field types from #2281 review; commodities de…
Flix6x Jul 10, 2026
7694615
merge: bring in user-commitment-name namespacing (#2285)
Flix6x Jul 10, 2026
8e43a9e
feat(ui): edit flex-context commitments in the context modal (#1572)
Flix6x Jul 10, 2026
2cc71fb
docs: point commitments-editor changelog entries at PR #2287
Flix6x Jul 10, 2026
b4da4a4
Merge remote-tracking branch 'origin/main' into feat/2230-ui-commodities
Flix6x Jul 19, 2026
8a6c6ff
fix(review): stop exposing internal commodities field to users
Flix6x Jul 24, 2026
85aada2
Merge remote-tracking branch 'origin/main' into HEAD
Flix6x Jul 24, 2026
2e18a59
Merge remote-tracking branch 'origin/feat/2230-ui-commodities' into HEAD
Flix6x Jul 24, 2026
b197cbc
feat(ui): commitment editor UX improvements from review
Flix6x Jul 24, 2026
6c152e9
fix(ui): clear stale commitments widget and stop About-box blink
Flix6x Jul 24, 2026
1fb126c
test: expect namespaced commitment names in multi-commitment costs
Flix6x Jul 11, 2026
2770f2a
Merge remote-tracking branch 'origin/main' into HEAD
Flix6x Jul 24, 2026
209d905
fix(ui): address Copilot review round 1
Flix6x Jul 24, 2026
6640d0e
fix(ui): derive default commitment price currency from the flex-context
Flix6x Jul 24, 2026
7a3cb90
fix: address Copilot review round 3 (robustness)
Flix6x Jul 24, 2026
db57095
fix(ui): point sensor-search label at the actual search input
Flix6x Jul 24, 2026
222dfd7
refactor: replace custom: name prefix with commitment provenance tagging
Flix6x Jul 24, 2026
cdaa225
fix: warn once per colliding commitment name; drop PR refs from test …
Flix6x Jul 24, 2026
a190b3b
fix: convert commitments saved within commodity contexts
Flix6x Jul 24, 2026
0e2eef7
fix: require commitment baseline at field level; test empty-name reje…
Flix6x Jul 24, 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
2 changes: 2 additions & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ New features
* Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 <https://www.github.com/FlexMeasures/flexmeasures/pull/2222>`_]
* Support multiple feeders to a shared storage [see `PR #2001 <https://www.github.com/FlexMeasures/flexmeasures/pull/2001>`_, `PR #2321 <https://www.github.com/FlexMeasures/flexmeasures/pull/2321>`_, `PR #2322 <https://www.github.com/FlexMeasures/flexmeasures/pull/2322>`_ and `PR #2325 <https://www.github.com/FlexMeasures/flexmeasures/pull/2325>`_]
* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 <https://www.github.com/FlexMeasures/flexmeasures/pull/1946>`_, `PR #2172 <https://www.github.com/FlexMeasures/flexmeasures/pull/2172>`_, `PR #2235 <https://www.github.com/FlexMeasures/flexmeasures/pull/2235>`_ and `PR #2271 <https://www.github.com/FlexMeasures/flexmeasures/pull/2271>`_]
* In the UI, the flex-context editor supports editing commitments (name, baseline and deviation prices, each accepting a fixed value or a sensor), also within each commodity context; the commitment's commodity follows the commodity tab being edited, and new commitments start with zero-valued baseline and prices [see `PR #2287 <https://www.github.com/FlexMeasures/flexmeasures/pull/2287>`_]
* A commitment in the flex-context now requires a ``baseline`` and at least one deviation price (``up-price`` and/or ``down-price``), as already documented; commitment costs are reported in the scheduling results under the user-given name (with a ``(custom)`` suffix in the rare case the name collides with a scheduler-internal commitment name) [see `PR #2287 <https://www.github.com/FlexMeasures/flexmeasures/pull/2287>`_]
* Commodity contexts that omit grid-connection fields (prices and site capacities) now get smart defaults instead of failing or silently leaving the grid unconstrained — for instance, a bare ``{"commodity": "gas"}`` is treated as having no grid connection; see :ref:`commodity_context_defaults` for the full rules [see `PR #2272 <https://www.github.com/FlexMeasures/flexmeasures/pull/2272>`_]
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/2242>`_]
* Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 <https://www.github.com/FlexMeasures/flexmeasures/pull/2244>`_]
Expand Down
4 changes: 4 additions & 0 deletions flexmeasures/data/models/planning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,10 @@ class Commitment:
#: When set, the solver couples the commitment to the stock group as a whole,
#: rather than to the device index named by ``device``.
stock: int | None = None
#: Who defined the commitment: "scheduler" for commitments the scheduler sets up
#: internally, "custom" for user-given commitments (from the flex-context).
#: Used to disambiguate cost reporting when names collide.
provenance: str = "scheduler"

def __post_init__(self):
# device_group is a device→label lookup table, not a time series;
Expand Down
65 changes: 57 additions & 8 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -1535,15 +1535,37 @@ def convert_to_commitments(
flex_model,
**timing_kwargs,
) -> list[FlowCommitment | StockCommitment]:
"""Convert list of commitment specifications (dicts) to a list of FlowCommitments."""
commitment_specs = self.flex_context.get("commitments", [])
"""Convert list of commitment specifications (dicts) to a list of FlowCommitments.

Commitments are collected from the top-level flex-context and from each
commodity context; a commitment within a commodity context always binds that
context's commodity (matching how the UI editor scopes commitments per
commodity tab).

User-given commitment names are kept as is, but the resulting commitments are
tagged with provenance "custom", so cost reporting can tell them apart from the
commitments the scheduler sets up internally (e.g. "electricity net energy").
"""
commitment_specs = [
dict(spec) for spec in self.flex_context.get("commitments", []) or []
]
for commodity_context in self.flex_context.get("commodity_contexts", []):
for spec in commodity_context.get("commitments", []) or []:
spec = dict(spec)
# A commitment in a commodity context binds that commodity
# (overriding the schema's electricity default on the field).
spec["commodity"] = commodity_context.get("commodity", "electricity")
commitment_specs.append(spec)
if len(commitment_specs) == 0:
return []

start, end = timing_kwargs["query_window"]
price_unit = self.flex_context["shared_currency_unit"] + "/MW"
commitments = []
# The specs were copied above, so converting (which pops fields) does not
# mutate self.flex_context and repeated conversions see the original specs.
for commitment_spec in commitment_specs:

# Convert baseline, up_price and down_price to pd.Series, then create FlowCommitment
if "up_price" in commitment_spec:
commitment_spec["upwards_deviation_price"] = (
Expand Down Expand Up @@ -1579,6 +1601,7 @@ def convert_to_commitments(
commitment = FlowCommitment(
device=d,
device_group=device_commodity,
provenance="custom",
**commitment_spec,
)
commitments.append(commitment)
Expand Down Expand Up @@ -3300,12 +3323,9 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
commitment_costs = [
{
"name": "commitment_costs",
"data": {
c.name: costs
for c, costs in zip(
commitments, model.commitment_costs.values()
)
},
"data": report_commitment_costs_by_name(
commitments, model.commitment_costs.values()
),
"unit": self.flex_context["shared_currency_unit"],
},
]
Expand Down Expand Up @@ -3590,6 +3610,35 @@ def add_storage_constraints(
return storage_device_constraints


def report_commitment_costs_by_name(commitments, costs) -> dict[str, float]:
"""Key commitment costs by commitment name.

Costs of same-named commitments of the same provenance are summed (e.g. one
custom commitment bound per device). A custom commitment whose name collides
with a scheduler-internal one is reported under "<name> (custom)" instead,
to keep both cost entries readable.
"""
scheduler_commitment_names = {
c.name for c in commitments if c.provenance == "scheduler"
}
costs_by_name: dict[str, float] = {}
colliding_names = set()
for c, cost in zip(commitments, costs):
key = c.name
if c.provenance == "custom" and key in scheduler_commitment_names:
key = f"{c.name} (custom)"
colliding_names.add(c.name)
costs_by_name[key] = costs_by_name.get(key, 0) + cost
# Warn once per colliding name (custom commitments are bound per device).
for name in sorted(colliding_names):
current_app.logger.warning(
f"Custom commitment '{name}' shares its name with a commitment"
f" the scheduler sets up internally; reporting its costs as"
f" '{name} (custom)'. Consider renaming the commitment."
)
return costs_by_name


def validate_storage_constraints(
constraints: pd.DataFrame,
soc_at_start: float,
Expand Down
92 changes: 91 additions & 1 deletion flexmeasures/data/models/planning/tests/test_commitments.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import numpy as np

from flexmeasures.data.services.utils import get_or_create_model
from flexmeasures.utils.unit_utils import ur
from flexmeasures.data.models.planning import (
Commitment,
StockCommitment,
Expand All @@ -17,7 +18,6 @@
from flexmeasures.data.models.planning.linear_optimization import device_scheduler
from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
from flexmeasures.data.utils import save_to_db
from flexmeasures.utils.unit_utils import ur


def test_multi_feed_device_scheduler_shared_buffer():
Expand Down Expand Up @@ -1803,6 +1803,43 @@ def test_electricity_device_indices_exclude_other_commodities():
assert scheduler._electricity_device_indices() == [0, 2, 3, 4]


def test_user_commitment_names_and_provenance(app):
"""User-given commitment names are kept as is, and the resulting commitments are
tagged with provenance "custom" (used to disambiguate the name-keyed cost report
when a user name collides with a scheduler-internal commitment name).
"""
scheduler = object.__new__(StorageScheduler)
start = pd.Timestamp("2024-01-01T00:00:00+01:00")
end = pd.Timestamp("2024-01-01T04:00:00+01:00")
resolution = pd.Timedelta("1h")
scheduler.flex_context = {
"shared_currency_unit": "EUR",
"commitments": [
{
# Deliberately shadowing an internal commitment name
"name": "electricity net energy",
"baseline": ur.Quantity("0 MW"),
"up_price": ur.Quantity("100 EUR/MWh"),
},
],
}
flex_model = [{"commodity": "electricity"}]

commitments = scheduler.convert_to_commitments(
flex_model,
query_window=(start, end),
resolution=resolution,
beliefs_before=start,
)
assert len(commitments) == 1
assert commitments[0].name == "electricity net energy"
assert commitments[0].provenance == "custom"

# Converting must not mutate the original specs (e.g. on repeated conversions).
assert scheduler.flex_context["commitments"][0]["name"] == "electricity net energy"
assert "baseline" in scheduler.flex_context["commitments"][0]


def _shared_stock_scheduler(db, flex_model, label):
"""Set up a battery with two inverter power sensors and one SoC sensor.

Expand Down Expand Up @@ -2046,3 +2083,56 @@ def test_commitment_commodity_does_not_bind_other_commodity_devices():
# the electricity device (index 0), not the gas device (index 1).
assert (electricity_commitment.device == 0).all()
assert set(electricity_commitment.device_group.unique()) == {"electricity"}


def test_commitments_in_commodity_contexts_are_converted(app):
"""Commitments saved within a commodity context (as the UI editor does per
commodity tab) are picked up by the scheduler and bind that context's commodity.
"""
scheduler = object.__new__(StorageScheduler)
start = pd.Timestamp("2024-01-01T00:00:00+01:00")
end = pd.Timestamp("2024-01-01T03:00:00+01:00")
resolution = pd.Timedelta("1h")
scheduler.flex_context = {
"shared_currency_unit": "EUR",
"commitments": [
{
"name": "top-level commitment",
"baseline": ur.Quantity("0 MW"),
"up_price": ur.Quantity("1 EUR/MWh"),
},
],
"commodity_contexts": [
{
"commodity": "gas",
"commitments": [
{
"name": "gas context commitment",
# Even the schema's electricity default gets overridden
# by the surrounding context's commodity.
"commodity": "electricity",
"baseline": ur.Quantity("1 MW"),
"up_price": ur.Quantity("2 EUR/MWh"),
},
],
},
],
}
# Flexible devices: 0 = electricity, 1 = gas.
flex_model = [{"commodity": "electricity"}, {"commodity": "gas"}]

commitments = scheduler.convert_to_commitments(
flex_model,
query_window=(start, end),
resolution=resolution,
beliefs_before=start,
)
assert len(commitments) == 2
gas_commitment = next(c for c in commitments if c.name == "gas context commitment")
assert (gas_commitment.device == 1).all()
assert set(gas_commitment.device_group.unique()) == {"gas"}

# The original specs (including the nested ones) are not mutated.
nested_spec = scheduler.flex_context["commodity_contexts"][0]["commitments"][0]
assert "baseline" in nested_spec
assert nested_spec["commodity"] == "electricity"
1 change: 1 addition & 0 deletions flexmeasures/data/models/planning/tests/test_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3515,6 +3515,7 @@ def test_flex_context_commitments_target_devices_not_stock_only_entries(
"commitments": [
{
"name": "test commitment",
"baseline": "0 kW", # the commitment schema requires an explicit baseline
"up-price": "1 EUR/MWh",
"down-price": "-1 EUR/MWh",
}
Expand Down
15 changes: 13 additions & 2 deletions flexmeasures/data/models/planning/tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,18 @@ def test_battery_solver_multi_commitment(add_battery_assets, db):
},
{
"name": "a sample commitment penalizing demand/supply",
# "baseline": "0 kW", # commented out to check defaulting to 0 also works
"baseline": "0 kW", # the commitment schema requires an explicit baseline
"up-price": "1 EUR/MWh",
"down-price": "-1 EUR/MWh",
},
{
# Deliberately shadows a scheduler-internal commitment name,
# to check its costs get reported under a "(custom)" suffix.
# Zero prices, so it does not affect the schedule or other costs.
"name": "electricity net energy",
"baseline": "0 kW",
"up-price": "0 EUR/MWh",
},
],
# The following is a constant price, but this checks currency conversion in case a later price field is
# set to a time series specs (i.e. a list of dicts, where each dict represents a time slot)
Expand Down Expand Up @@ -154,13 +162,16 @@ def test_battery_solver_multi_commitment(add_battery_assets, db):
# No production peak
np.testing.assert_almost_equal(costs["electricity production peak"], 0)

# Sample commitments
# Sample commitments (reported under their user-given names)
np.testing.assert_almost_equal(
costs["a sample commitment penalizing peaks"], 4 * (1 - 0.4)
)
np.testing.assert_almost_equal(
costs["a sample commitment penalizing demand/supply"], 1 * (1 - 0.4)
)
# The name-colliding custom commitment is reported under a "(custom)" suffix,
# leaving the internal "electricity net energy" cost entry untouched (see above).
np.testing.assert_almost_equal(costs["electricity net energy (custom)"], 0)

# Check consumption/production output sensor schedules.
# The battery charges at a constant rate (all positive values), so the consumption schedule
Expand Down
26 changes: 23 additions & 3 deletions flexmeasures/data/schemas/scheduling/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ def forbid_time_series_specs(self, data: dict, **kwargs):


class CommitmentSchema(Schema):
name = fields.Str(required=True, data_key="name")
# Undocumented for now (not part of UI_FLEX_CONTEXT_SCHEMA, OpenAPI or Sphinx docs).
name = fields.Str(required=True, data_key="name", validate=validate.Length(min=1))
# Not described in UI_FLEX_CONTEXT_SCHEMA or the Sphinx docs (it does show up
# in the generated OpenAPI schema, without being promoted in field descriptions).
# Internal bookkeeping only: not the documented way to associate a commitment
# with a commodity. API users should instead place the commitment under the
# relevant entry of the multi-commodity `commodities` list (one flex-context
Expand All @@ -85,14 +86,33 @@ class CommitmentSchema(Schema):
load_default="electricity",
data_key="commodity",
)
baseline = VariableQuantityField("MW", required=False, data_key="baseline")
baseline = VariableQuantityField(
"MW",
required=True,
data_key="baseline",
error_messages={"required": "A commitment requires a baseline."},
)
up_price = VariableQuantityField("/MW", required=False, data_key="up-price")
down_price = VariableQuantityField(
"/MW",
required=False,
data_key="down-price",
)

@validates_schema
def require_a_price(self, commitment, **kwargs):
"""A commitment is worthless without at least one deviation price.

The baseline requirement is enforced at the field level (required=True),
so it also shows up in the generated OpenAPI schema; the either/or price
requirement cannot be expressed per field.
"""
if "up_price" not in commitment and "down_price" not in commitment:
raise ValidationError(
"A commitment requires at least one deviation price (up-price and/or down-price).",
field_name="up-price",
)

@validates_schema
def check_units(self, commitment, **kwargs):
baseline_field = self.declared_fields["baseline"]
Expand Down
12 changes: 10 additions & 2 deletions flexmeasures/data/schemas/scheduling/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,16 @@ def to_dict(self):
example={"sensor": 11},
)
COMMITMENTS = MetaData(
description="Prior commitments. Support for this field in the UI is still under further development, but you can find more information in :ref:`commitments`.",
example=[],
description="""Prior commitments. Each commitment needs a ``name`` and a ``baseline``, plus at least one deviation price (``up-price`` and/or ``down-price``); its ``commodity`` defaults to electricity.
You can find more information in :ref:`commitments`.
""",
example=[
{
"name": "capacity contract",
"baseline": "100 kW",
"up-price": {"sensor": 5},
}
],
)
CONSUMPTION_PRICE = MetaData(
description="The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_",
Expand Down
39 changes: 39 additions & 0 deletions flexmeasures/data/schemas/tests/test_scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,45 @@ def load_schema():
"commitments.0.up-price": "Commitment up-price must have a power or energy unit in its denominator."
},
),
# Commitment without a baseline
(
{
"commitments": [
{
"name": "a sample commitment",
"up-price": "100 EUR/MWh",
}
]
},
{"commitments.0.baseline": "A commitment requires a baseline."},
),
Comment thread
Flix6x marked this conversation as resolved.
# Commitment with an empty name
(
{
"commitments": [
{
"name": "",
"baseline": "10 kW",
"up-price": "100 EUR/MWh",
}
]
},
{"commitments.0.name": "Shorter than minimum length 1."},
),
# Commitment without any deviation price
(
{
"commitments": [
{
"name": "a sample commitment",
"baseline": "10 kW",
}
]
},
{
"commitments.0.up-price": "A commitment requires at least one deviation price (up-price and/or down-price)."
},
),
],
)
def test_flex_context_schema(
Expand Down
Loading
Loading