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.
EMS-level flow commitments are silently unenforced (free rows) since #1946
Summary
ems_flow_commitment_equalitiesinflexmeasures/data/models/planning/linear_optimization.pybuilds 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 (commit003c145c5e, merged ~2026-07-08).Root cause
Current code returns a tuple with hardcoded
Nonebounds: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 were0/0(a genuine equality), matching the sibling rulegrouped_commitment_equalities, which still correctly returns0 if "... deviation price" in columns else None.Reproduction
The constraint objects exist but carry
lower=None, upper=Noneand produce zero rows in the exported LP/MPS.Impact
commitment_quantities=argument todevice_scheduler(public, documented) routes here → unbounded.FlowCommitment(constructed withoutdevice=) is silently ignored.FlowCommitmentpassesdevice=, so it routes throughgrouped_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_equalitiesfor an existing fix, to avoid duplicating or conflicting with it. Confirm againstmainwith the reproduction above before writing new code.Suggested fix
Restore the bounds the way
grouped_commitment_equalitiesdoes it, e.g.:and add a regression test that runs
device_schedulerwith an EMS-level commitment and assertstermination_condition == "optimal"plus a non-trivial objective.Found and written up with Claude Code assistance while benchmarking the scheduler.