From e5a5cd79e20cd74ffd8605da23dc45224a3bda93 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Wed, 22 Jul 2026 20:16:33 +0530 Subject: [PATCH 1/4] Keep explicit zero directional capacity as a hard bound With relax-constraints defaulting to True, an explicit production-capacity: 0 (or consumption-capacity: 0) was turned into a soft FlowCommitment whenever breach prices were present. The LP could then schedule power in a physically impossible direction (e.g. produce from a heat pump). Treat an all-zero directional capacity as a physical impossibility: keep the hard derivative bound and skip soft breach commitments. Non-zero (economic) capacity limits may still be relaxed. Fixes #2323 Signed-off-by: Sankalp Thakur --- documentation/changelog.rst | 1 + flexmeasures/data/models/planning/storage.py | 14 +- .../data/models/planning/tests/test_solver.py | 139 ++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index e22e27187f..3d1e01e5f3 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -62,6 +62,7 @@ Bugfixes ----------- * Scheduling jobs no longer print ``Job ... made schedule.`` before ``scheduler.compute()`` runs (only after a successful schedule) [see `PR #2342 `_] * ``flexmeasures add user --roles`` now correctly accepts a comma-separated list of roles (and repeated ``--roles`` options) instead of creating one role whose name contains commas [see `PR #2339 `_] +* Keep an explicit zero ``production-capacity`` or ``consumption-capacity`` as a hard device bound under ``relax-constraints``, so one-way devices cannot be scheduled in the forbidden direction [see `PR #2345 `_] * Raise a clear ``ValueError`` when a flex-model references a missing sensor ID instead of ``AttributeError: 'NoneType' object has no attribute 'asset_id'`` [see `PR #2343 `_] * Fix column sorting on the assets page, including when combined with the search filter [see `PR #2314 `_] * Fix forecasting with past or future regressors, which raised a ``TypeError`` on pandas 2.2 and higher [see `PR #2303 `_] diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 2c2211e439..5489dac8d0 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1007,11 +1007,16 @@ def device_list_series( min_value=0, # capacities are positive by definition resolve_overlaps="min", ) + # Explicit zero production capacity is a physical impossibility (e.g. + # a heat pump), not an economic limit — keep it hard even when + # relax-constraints injects production-breach-price. Non-zero + # production capacities may still be softened. See issue #2323. if ( self.flex_context.get("production_breach_price") is not None and production_capacity[d] is not None + and production_capacity_d.max() > 0 ): - # consumption-capacity will become a soft constraint + # production-capacity will become a soft constraint production_breach_price = self.flex_context[ "production_breach_price" ] @@ -1058,7 +1063,7 @@ def device_list_series( ) commitments.append(commitment) else: - # consumption-capacity will become a hard constraint + # production-capacity will become a hard constraint device_constraints[d]["derivative min"] = -production_capacity_d if sensor_d is not None and sensor_d.get_attribute( "is_strictly_non_negative" @@ -1078,9 +1083,14 @@ def device_list_series( max_value=power_capacity_in_mw[d], resolve_overlaps="min", ) + # Explicit zero consumption capacity is a physical impossibility + # (e.g. a pure generator), not an economic limit — keep it hard + # even when relax-constraints injects consumption-breach-price. + # Non-zero consumption capacities may still be softened. See #2323. if ( self.flex_context.get("consumption_breach_price") is not None and consumption_capacity[d] is not None + and consumption_capacity_d.max() > 0 ): # consumption-capacity will become a soft constraint consumption_breach_price = self.flex_context[ diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 15e29f0f1e..ad5cad11bb 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1653,6 +1653,145 @@ def test_device_power_capacity_uses_directional_capacity_before_site_fallback( assert np.allclose(device_constraints[0]["derivative max"], expected_derivative_max) +@pytest.mark.parametrize( + "direction, capacity_field, expected_derivative_col, expected_bound", + [ + ("production", "production-capacity", "derivative min", 0.0), + ("consumption", "consumption-capacity", "derivative max", 0.0), + ], +) +def test_explicit_zero_directional_capacity_stays_hard_under_relax_constraints( + db, + add_battery_assets, + direction, + capacity_field, + expected_derivative_col, + expected_bound, +): + """Explicit zero directional capacity is physical, not economic (#2323). + + With ``relax-constraints`` defaulting to True, non-zero capacity limits may be + softened via breach prices. An explicit ``production-capacity: 0`` (or + consumption-capacity: 0) must remain a hard bound — otherwise a consumption-only + device (e.g. heat pump) can be scheduled to produce when site breaches are + more expensive than device breaches. + """ + _, battery = get_sensors_from_db(db, add_battery_assets) + + start = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 2)) + end = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 3)) + resolution = timedelta(minutes=15) + + # Opposite direction stays open so the symmetric power-capacity fallback is not zero. + opposite_field = ( + "consumption-capacity" if direction == "production" else "production-capacity" + ) + scheduler = StorageScheduler( + asset_or_sensor=battery, + start=start, + end=end, + resolution=resolution, + flex_model={ + "soc-at-start": "1 MWh", + "soc-min": "0 MWh", + "soc-max": "2 MWh", + "power-capacity": "2 MW", + capacity_field: "0 kW", + opposite_field: "2 MW", + }, + # Default relax-constraints=True injects device breach prices. + flex_context={ + "consumption-price": "1 EUR/MWh", + "production-price": "1000 EUR/MWh", # strong incentive to produce + # Cheap device breaches vs expensive site breaches — the incentive that + # previously made soft zero-capacity look rational to the LP. + "production-breach-price": "100 EUR/kW", + "consumption-breach-price": "100 EUR/kW", + "site-production-breach-price": "10000 EUR/kW", + "site-consumption-breach-price": "10000 EUR/kW", + "site-power-capacity": "1 kW", + }, + ) + scheduler.deserialize_config() + + ( + _sensors, + _start, + _end, + _resolution, + _soc_at_start, + device_constraints, + _ems_constraints, + commitments, + ) = scheduler._prepare(skip_validation=True) + + assert np.allclose( + device_constraints[0][expected_derivative_col], expected_bound + ), ( + f"explicit zero {capacity_field} must stay a hard {expected_derivative_col} " + f"bound under relax-constraints, got " + f"{device_constraints[0][expected_derivative_col].unique()}" + ) + + soft_breach_names = [ + c.name + for c in commitments + if c.name is not None and f"{direction} breach device" in c.name + ] + assert soft_breach_names == [], ( + f"explicit zero {capacity_field} must not create soft " + f"{direction} breach commitments, got {soft_breach_names}" + ) + + +def test_explicit_zero_production_capacity_not_breached_in_schedule( + db, add_battery_assets +): + """Regression for #2323: never schedule production when production-capacity is 0. + + High production price makes discharging highly attractive. With an explicit + ``production-breach-price`` (as ``relax-constraints`` would inject), a soft + interpretation of ``production-capacity: 0`` would allow negative power. + Zero must stay hard, so the schedule never produces. + """ + _, battery = get_sensors_from_db(db, add_battery_assets) + + start = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 1)) + end = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 2)) + resolution = timedelta(minutes=15) + + scheduler = StorageScheduler( + asset_or_sensor=battery, + start=start, + end=end, + resolution=resolution, + flex_model={ + "soc-at-start": "1 MWh", + "soc-min": "0 MWh", + "soc-max": "2 MWh", + "power-capacity": "0.1 MW", + "production-capacity": "0 kW", + "consumption-capacity": "0.1 MW", + "prefer-charging-sooner": False, + }, + flex_context={ + # Strong incentive to discharge (produce) if allowed. + "consumption-price": "1 EUR/MWh", + "production-price": "1000 EUR/MWh", + # Breach price that would soften non-zero production limits; must not + # soften an explicit zero. Keep other constraints hard for a simple LP. + "production-breach-price": "100 EUR/kW", + "site-power-capacity": "2 MW", + "relax-constraints": False, + }, + ) + schedule = scheduler.compute() + assert (schedule >= -TOLERANCE).all(), ( + "production-capacity: 0 must forbid negative (production) power even when " + f"production-breach-price is set; min scheduled power was {schedule.min()}" + ) + + @pytest.mark.parametrize( ["soc_values", "log_message", "expected_num_targets"], [ From f77052d77e6db54b2f212e28d88e3bdad58e70de Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Wed, 22 Jul 2026 20:33:40 +0530 Subject: [PATCH 2/4] test: non-zero directional capacity still softens under breach prices Companion regression for #2323: only explicit zero stays hard; economic non-zero production/consumption capacities must still create soft breach commitments. Signed-off-by: Sankalp Thakur --- .../data/models/planning/tests/test_solver.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index ad5cad11bb..db1734d0be 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1744,6 +1744,92 @@ def test_explicit_zero_directional_capacity_stays_hard_under_relax_constraints( ) +@pytest.mark.parametrize( + "direction, capacity_field, expected_derivative_col", + [ + ("production", "production-capacity", "derivative min"), + ("consumption", "consumption-capacity", "derivative max"), + ], +) +def test_non_zero_directional_capacity_still_softens_under_breach_price( + db, + add_battery_assets, + direction, + capacity_field, + expected_derivative_col, +): + """Non-zero directional capacity remains soft when breach prices are set (#2323). + + Companion to ``test_explicit_zero_directional_capacity_stays_hard_under_relax_constraints``: + only the all-zero case stays hard; economic (non-zero) limits must still create soft + breach commitments and must not pin the hard derivative bound to that capacity. + """ + _, battery = get_sensors_from_db(db, add_battery_assets) + + start = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 2)) + end = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 3)) + resolution = timedelta(minutes=15) + + opposite_field = ( + "consumption-capacity" if direction == "production" else "production-capacity" + ) + scheduler = StorageScheduler( + asset_or_sensor=battery, + start=start, + end=end, + resolution=resolution, + flex_model={ + "soc-at-start": "1 MWh", + "soc-min": "0 MWh", + "soc-max": "2 MWh", + "power-capacity": "2 MW", + capacity_field: "0.1 MW", # non-zero economic limit + opposite_field: "2 MW", + }, + flex_context={ + "consumption-price": "1 EUR/MWh", + "production-price": "1 EUR/MWh", + "production-breach-price": "100 EUR/kW", + "consumption-breach-price": "100 EUR/kW", + "site-power-capacity": "2 MW", + }, + ) + scheduler.deserialize_config() + + ( + _sensors, + _start, + _end, + _resolution, + _soc_at_start, + device_constraints, + _ems_constraints, + commitments, + ) = scheduler._prepare(skip_validation=True) + + soft_breach_names = [ + c.name + for c in commitments + if c.name is not None and f"{direction} breach device" in c.name + ] + assert soft_breach_names, ( + f"non-zero {capacity_field} must still create soft " + f"{direction} breach commitments under breach prices" + ) + + # Hard bound should stay at power-capacity (±2 MW), not be pinned to the soft 0.1 MW. + if expected_derivative_col == "derivative min": + assert np.allclose(device_constraints[0][expected_derivative_col], -2.0), ( + f"hard {expected_derivative_col} should remain power-capacity when " + f"non-zero {capacity_field} is softened" + ) + else: + assert np.allclose(device_constraints[0][expected_derivative_col], 2.0), ( + f"hard {expected_derivative_col} should remain power-capacity when " + f"non-zero {capacity_field} is softened" + ) + + def test_explicit_zero_production_capacity_not_breached_in_schedule( db, add_battery_assets ): From 8ec714967eb1ad1d7a0e066a62ed0c8024d8fbd1 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Wed, 22 Jul 2026 23:48:16 +0530 Subject: [PATCH 3/4] test: cover consumption-capacity 0 schedule path for #2323 Parametrize the end-to-end schedule regression so both production and consumption explicit zeros stay hard under directional breach prices. Signed-off-by: Sankalp Thakur --- .../data/models/planning/tests/test_solver.py | 80 ++++++++++++++----- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index db1734d0be..3f4221584e 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1830,15 +1830,53 @@ def test_non_zero_directional_capacity_still_softens_under_breach_price( ) -def test_explicit_zero_production_capacity_not_breached_in_schedule( - db, add_battery_assets +@pytest.mark.parametrize( + [ + "capacity_field", + "open_field", + "breach_price_field", + "prices", + "forbidden_direction", + ], + [ + ( + "production-capacity", + "consumption-capacity", + "production-breach-price", + { + # Strong incentive to discharge (produce) if allowed. + "consumption-price": "1 EUR/MWh", + "production-price": "1000 EUR/MWh", + }, + "produce", + ), + ( + "consumption-capacity", + "production-capacity", + "consumption-breach-price", + { + # Paid to consume — strong incentive to charge if allowed. + "consumption-price": "-1000 EUR/MWh", + "production-price": "1 EUR/MWh", + }, + "consume", + ), + ], +) +def test_explicit_zero_directional_capacity_not_breached_in_schedule( + db, + add_battery_assets, + capacity_field, + open_field, + breach_price_field, + prices, + forbidden_direction, ): - """Regression for #2323: never schedule production when production-capacity is 0. + """Regression for #2323: never schedule in a direction with explicit capacity 0. - High production price makes discharging highly attractive. With an explicit - ``production-breach-price`` (as ``relax-constraints`` would inject), a soft - interpretation of ``production-capacity: 0`` would allow negative power. - Zero must stay hard, so the schedule never produces. + A soft interpretation of an all-zero directional capacity (via breach prices + that ``relax-constraints`` would inject) would allow power in a physically + impossible direction. Zero must stay hard for both production and consumption. """ _, battery = get_sensors_from_db(db, add_battery_assets) @@ -1856,26 +1894,30 @@ def test_explicit_zero_production_capacity_not_breached_in_schedule( "soc-min": "0 MWh", "soc-max": "2 MWh", "power-capacity": "0.1 MW", - "production-capacity": "0 kW", - "consumption-capacity": "0.1 MW", + capacity_field: "0 kW", + open_field: "0.1 MW", "prefer-charging-sooner": False, }, flex_context={ - # Strong incentive to discharge (produce) if allowed. - "consumption-price": "1 EUR/MWh", - "production-price": "1000 EUR/MWh", - # Breach price that would soften non-zero production limits; must not - # soften an explicit zero. Keep other constraints hard for a simple LP. - "production-breach-price": "100 EUR/kW", + **prices, + # Breach price that would soften non-zero limits; must not soften + # an explicit zero. Keep other constraints hard for a simple LP. + breach_price_field: "100 EUR/kW", "site-power-capacity": "2 MW", "relax-constraints": False, }, ) schedule = scheduler.compute() - assert (schedule >= -TOLERANCE).all(), ( - "production-capacity: 0 must forbid negative (production) power even when " - f"production-breach-price is set; min scheduled power was {schedule.min()}" - ) + if forbidden_direction == "produce": + assert (schedule >= -TOLERANCE).all(), ( + f"{capacity_field}: 0 must forbid negative (production) power even when " + f"{breach_price_field} is set; min scheduled power was {schedule.min()}" + ) + else: + assert (schedule <= TOLERANCE).all(), ( + f"{capacity_field}: 0 must forbid positive (consumption) power even when " + f"{breach_price_field} is set; max scheduled power was {schedule.max()}" + ) @pytest.mark.parametrize( From 1658c132ce866d170667dc3e080f55203dc8725d Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Thu, 23 Jul 2026 01:03:12 +0530 Subject: [PATCH 4/4] test: cross-ref #2329 EVSE discharge in zero-capacity regression Signed-off-by: Sankalp Thakur Co-authored-by: Cursor --- flexmeasures/data/models/planning/tests/test_solver.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 3f4221584e..16eb752d24 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1877,6 +1877,9 @@ def test_explicit_zero_directional_capacity_not_breached_in_schedule( A soft interpretation of an all-zero directional capacity (via breach prices that ``relax-constraints`` would inject) would allow power in a physically impossible direction. Zero must stay hard for both production and consumption. + + Same failure mode as EV chargers scheduled to discharge despite + ``production-capacity: 0 W`` in #2329. """ _, battery = get_sensors_from_db(db, add_battery_assets)