Skip to content

EMS-level flow commitments are silently unenforced (free rows) since #1946 #2286

Description

@Flix6x

EMS-level flow commitments are silently unenforced (free rows) since #1946

Summary

ems_flow_commitment_equalities in flexmeasures/data/models/planning/linear_optimization.py builds free rows (no lower or upper bound), so EMS-level commitments impose no constraint at all. The deviation variables float free and the model becomes unbounded. This regressed in #1946 (commit 003c145c5e, merged ~2026-07-08).

Root cause

Current code returns a tuple with hardcoded None bounds:

return (
    None,
    m.commitment_quantity[c, j]
    + m.commitment_downwards_deviation[c]
    + m.commitment_upwards_deviation[c]
    - sum(m.ems_power[d, j] for d in devices),
    None,
)

In Pyomo, (None, expr, None) is a constraint with no bounds — an "equality" to nothing — which is dropped from the model. Before #1946 the bounds were 0 / 0 (a genuine equality), matching the sibling rule grouped_commitment_equalities, which still correctly returns 0 if "... deviation price" in columns else None.

Reproduction

from datetime import timedelta
import numpy as np, pandas as pd
from flask import Flask
app = Flask("repro"); app.config["FLEXMEASURES_LP_SOLVER"] = "appsi_highs"; app.config["LOGGING_LEVEL"] = "WARNING"
from flexmeasures.data.models.planning import FlowCommitment
from flexmeasures.data.models.planning.linear_optimization import device_scheduler
from flexmeasures.data.models.planning.utils import initialize_index

RES = timedelta(hours=1); start = pd.Timestamp("2026-01-01T00:00+01")
index = initialize_index(start=start, end=start + 6 * RES, resolution=RES)
batt = pd.DataFrame({"min": 0, "max": 10, "equals": np.nan, "derivative min": -5,
    "derivative max": 5, "derivative equals": np.nan,
    "derivative down efficiency": 0.95, "derivative up efficiency": 0.95}, index=index)
ems = pd.DataFrame({"derivative min": -15.0, "derivative max": 15.0}, index=index)
price = pd.Series(np.linspace(10, 60, 6), index=index)
# EMS-level commitment: no device= argument
comms = [FlowCommitment(name="net energy", index=index, quantity=pd.Series(0.0, index=index),
    upwards_deviation_price=price, downwards_deviation_price=price)]
with app.app_context():
    _p, cost, r, m = device_scheduler(device_constraints=[batt], ems_constraints=ems,
        commitments=comms, initial_stock=[5.0])
    print(r.solver.termination_condition)  # -> infeasibleOrUnbounded
    comp = m.ems_power_commitment_equalities
    print([(v.lower, v.upper) for _, v in comp.items()])  # -> [(None, None), ...]  free rows

The constraint objects exist but carry lower=None, upper=None and produce zero rows in the exported LP/MPS.

Impact

  • The legacy commitment_quantities= argument to device_scheduler (public, documented) routes here → unbounded.
  • Any EMS-level FlowCommitment (constructed without device=) is silently ignored.
  • Live schedulers are not affected: every production FlowCommitment passes device=, so it routes through grouped_commitment_equalities, which is still correct. This is why CI didn't catch it — there appears to be no test exercising an EMS-level commitment.

The dangerous part is the silence: no error, just a wrong (unbounded/ignored) model.

Before fixing: check for prior/partial work

We have a feeling this was spotted on our side before and may already be (partially) fixed on another recent branch. Whoever picks this up should first search open branches and recent PRs touching linear_optimization.py / ems_flow_commitment_equalities for an existing fix, to avoid duplicating or conflicting with it. Confirm against main with the reproduction above before writing new code.

Suggested fix

Restore the bounds the way grouped_commitment_equalities does it, e.g.:

return (
    0 if "upwards deviation price" in commitments[c].columns else None,
    <the flow expression>,
    0 if "downwards deviation price" in commitments[c].columns else None,
)

and add a regression test that runs device_scheduler with an EMS-level commitment and asserts termination_condition == "optimal" plus a non-trivial objective.


Found and written up with Claude Code assistance while benchmarking the scheduler.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions