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..16eb752d24 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1653,6 +1653,276 @@ 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}" + ) + + +@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" + ) + + +@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 in a direction with explicit capacity 0. + + 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) + + 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", + capacity_field: "0 kW", + open_field: "0.1 MW", + "prefer-charging-sooner": False, + }, + flex_context={ + **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() + 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( ["soc_values", "log_message", "expected_num_targets"], [