From 7612e461cfc449fae2c2e8b9bb8502cd7400055a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:21:55 +0000 Subject: [PATCH 1/2] Initial plan From 20cead8aaeed27c0aa4d44643ccfa26613c363c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:32:27 +0000 Subject: [PATCH 2/2] feat: implement curtailable-device-sensors scheduling logic and update tests Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- documentation/changelog.rst | 1 + .../tests/test_sensor_schedules_fresh_db.py | 14 +++++++---- flexmeasures/data/models/planning/storage.py | 23 +++++++++++++++++-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 0d04604a95..abb5face69 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -29,6 +29,7 @@ New features * Allow testing out the scheduling CLI without saving anything, using ``flexmeasures add schedule --dry-run`` [see `PR #1892 `_] * Allow unsupported ``flex-context`` or ``flex-model`` fields to be shown in the UI editors (they will be un-editable) [see `PR #1915 `_] * Add back save buttons to both ``flex-context`` and ``flex-model`` UI editors [see `PR #1916 `_] +* Add ``curtailable-device-sensors`` field to the flex-context, allowing the StorageScheduler to curtail devices (e.g. rooftop solar) as part of its optimization [see `PR #1937 `_] Infrastructure / Support diff --git a/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py b/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py index 1c70b12c9b..fa51922359 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py @@ -307,13 +307,17 @@ def test_price_sensor_priority( "context_sensor_num, asset_sensor_num, parent_sensor_num, expect_sensor_num", [ # Sensors are present in context and parent, use from context - (1, 0, 2, 1), + # +1 for the curtailable device sensor always present in the flex-context + (1, 0, 2, 2), # No sensors in context, have in asset and parent, use asset sensors - (0, 1, 2, 1), + # +1 for the curtailable device sensor always present in the flex-context + (0, 1, 2, 2), # No sensors in context and asset, use from parent asset - (0, 0, 1, 1), + # +1 for the curtailable device sensor always present in the flex-context + (0, 0, 1, 2), # Have sensors everywhere, use from context - (1, 2, 3, 1), + # +1 for the curtailable device sensor always present in the flex-context + (1, 2, 3, 2), ], ) @pytest.mark.parametrize( @@ -390,7 +394,7 @@ def test_inflexible_device_sensors_priority( ) as mock_storage_get_power_values: work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) - # Counting how many times power values (for inflexible sensors) were fetched (gives us the number of sensors) + # Counting how many times power values (for inflexible and curtailable sensors) were fetched (gives us the number of sensors) call_args = mock_storage_get_power_values.call_args_list assert len(call_args) == expect_sensor_num diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index a0d6f8da19..b8dd6fd61f 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -170,6 +170,9 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 inflexible_device_sensors = self.flex_context.get( "inflexible_device_sensors", [] ) + curtailable_device_sensors = self.flex_context.get( + "curtailable_device_sensors", [] + ) # Fetch the device's power capacity (required Sensor attribute) power_capacity_in_mw = self._get_device_power_capacity(flex_model, assets) @@ -462,10 +465,14 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) commitments.append(commitment) - # Set up device constraints: scheduled flexible devices for this EMS (from index 0 to D-1), plus the forecasted inflexible devices (at indices D to n). + # Set up device constraints: scheduled flexible devices for this EMS (from index 0 to D-1), plus the forecasted inflexible devices (at indices D to D+I-1), plus the curtailable devices (at indices D+I to D+I+C-1). device_constraints = [ initialize_df(StorageScheduler.COLUMNS, start, end, resolution) - for i in range(num_flexible_devices + len(inflexible_device_sensors)) + for i in range( + num_flexible_devices + + len(inflexible_device_sensors) + + len(curtailable_device_sensors) + ) ] for i, inflexible_sensor in enumerate(inflexible_device_sensors): device_constraints[i + num_flexible_devices]["derivative equals"] = ( @@ -476,6 +483,18 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 sensor=inflexible_sensor, ) ) + for i, curtailable_sensor in enumerate(curtailable_device_sensors): + idx = i + num_flexible_devices + len(inflexible_device_sensors) + forecast_values = get_power_values( + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + sensor=curtailable_sensor, + ) + # Curtailable devices can be reduced to zero power (curtailed), + # but cannot exceed their forecast (positive for consumers, negative for producers). + device_constraints[idx]["derivative min"] = forecast_values.clip(upper=0) + device_constraints[idx]["derivative max"] = forecast_values.clip(lower=0) # Create the device constraints for all the flexible devices for d in range(num_flexible_devices):