From ec1fec924dad6845b62554d425c2be2e892d7d78 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 4 Jun 2026 01:10:58 +0100 Subject: [PATCH 01/13] fix: allow storage constraints without soc-min Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/models/planning/storage.py | 49 +++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index b166ff0913..44829c3470 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1433,8 +1433,10 @@ def ensure_soc_min_max(self): """ soc_min_asset, soc_max_asset = self.get_min_max_soc_from_asset() if "soc-min" not in self.flex_model or self.flex_model["soc-min"] is None: - # Default is 0 - can't drain the storage by more than it contains - self.flex_model["soc-min"] = soc_min_asset if soc_min_asset else 0 + if soc_min_asset is not None: + self.flex_model["soc-min"] = soc_min_asset + else: + self.flex_model.pop("soc-min", None) if "soc-max" not in self.flex_model or self.flex_model["soc-max"] is None: self.flex_model["soc-max"] = soc_max_asset # Lacking information about the battery's nominal capacity, we use the highest target value as the maximum state of charge @@ -2033,7 +2035,7 @@ def add_storage_constraints( soc_maxima: list[dict[str, datetime | float]] | pd.Series | None, soc_minima: list[dict[str, datetime | float]] | pd.Series | None, soc_max: float, - soc_min: float, + soc_min: float | None, ) -> pd.DataFrame: """Collect all constraints for a given storage device in a DataFrame that the device_scheduler can interpret. @@ -2045,7 +2047,7 @@ def add_storage_constraints( :param soc_maxima: Maximum state of charge at each time. :param soc_minima: Minimum state of charge at each time. :param soc_max: Maximum state of charge at all times. - :param soc_min: Minimum state of charge at all times. + :param soc_min: Minimum state of charge at all times, if configured. :returns: Constraints (StorageScheduler.COLUMNS) for a storage device, at each time step (index). See device_scheduler for possible column names. """ @@ -2062,7 +2064,11 @@ def add_storage_constraints( soc_targets, soc_at_start, start, end, resolution ) - soc_min_change = (soc_min - soc_at_start) * timedelta(hours=1) / resolution + soc_min_change = ( + (soc_min - soc_at_start) * timedelta(hours=1) / resolution + if soc_min is not None + else None + ) soc_max_change = (soc_max - soc_at_start) * timedelta(hours=1) / resolution if soc_minima is not None: @@ -2074,9 +2080,11 @@ def add_storage_constraints( resolution, ) - storage_device_constraints["min"] = ( - storage_device_constraints["min"].astype(float).fillna(soc_min_change) - ) + storage_device_constraints["min"] = storage_device_constraints["min"].astype(float) + if soc_min_change is not None: + storage_device_constraints["min"] = storage_device_constraints["min"].fillna( + soc_min_change + ) if soc_maxima is not None: storage_device_constraints["max"] = build_device_soc_values( @@ -2092,8 +2100,12 @@ def add_storage_constraints( ) # limiting max and min to be in the range [soc_min, soc_max] - storage_device_constraints["min"] = storage_device_constraints["min"].clip( - lower=soc_min_change, upper=soc_max_change + storage_device_constraints["min"] = ( + storage_device_constraints["min"].clip( + lower=soc_min_change, upper=soc_max_change + ) + if soc_min_change is not None + else storage_device_constraints["min"].clip(upper=soc_max_change) ) storage_device_constraints["max"] = storage_device_constraints["max"].clip( lower=soc_min_change, upper=soc_max_change @@ -2105,7 +2117,7 @@ def add_storage_constraints( def validate_storage_constraints( constraints: pd.DataFrame, soc_at_start: float, - soc_min: float, + soc_min: float | None, soc_max: float, resolution: timedelta, ) -> list[dict]: @@ -2128,7 +2140,7 @@ def validate_storage_constraints( :param constraints: dataframe containing the constraints of a storage device :param soc_at_start: State of charge at the start time. - :param soc_min: Minimum state of charge at all times. + :param soc_min: Minimum state of charge at all times, if configured. :param soc_max: Maximum state of charge at all times. :param resolution: Constant duration between the start of each time step. :returns: List of constraint violations, specifying their time, constraint and violation. @@ -2152,11 +2164,14 @@ def validate_storage_constraints( ######################## # 1) min >= soc_min - soc_min = (soc_min - soc_at_start) * timedelta(hours=1) / resolution - _constraints["soc_min(t)"] = soc_min - constraint_violations += validate_constraint( - _constraints, "soc_min(t)", "<=", "min(t)" - ) + if soc_min is not None: + soc_min = (soc_min - soc_at_start) * timedelta(hours=1) / resolution + _constraints["soc_min(t)"] = soc_min + constraint_violations += validate_constraint( + _constraints, "soc_min(t)", "<=", "min(t)" + ) + else: + soc_min = np.nan # 2) max <= soc_max soc_max = (soc_max - soc_at_start) * timedelta(hours=1) / resolution From 6f31f269aa31d75cdf87182463d31fd158db99e1 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 4 Jun 2026 01:11:05 +0100 Subject: [PATCH 02/13] test: cover missing storage soc-min Signed-off-by: Mohamed Belhsan Hmida --- .../data/models/planning/tests/test_solver.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 2bd1321271..66fcca6ab0 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -931,6 +931,80 @@ def test_add_storage_constraints( ].all() +def test_add_storage_constraints_skips_global_minimum_when_soc_min_is_missing(): + """Missing soc-min should not imply a zero lower bound.""" + start = datetime(2023, 5, 18, tzinfo=pytz.utc) + end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc) + resolution = timedelta(hours=1) + soc_at_start = 0.0 + soc_max = 10 + soc_min = None + + storage_device_constraints = add_storage_constraints( + start, + end, + resolution, + soc_at_start, + soc_targets=None, + soc_maxima=None, + soc_minima=None, + soc_max=soc_max, + soc_min=soc_min, + ) + + assert storage_device_constraints["min"].isna().all() + assert (storage_device_constraints["max"] == soc_max).all() + assert ( + validate_storage_constraints( + storage_device_constraints, + soc_at_start=soc_at_start, + soc_min=soc_min, + soc_max=soc_max, + resolution=resolution, + ) + == [] + ) + + +def test_add_storage_constraints_with_soc_minima_and_missing_soc_min_has_gaps(): + """Timed minima should not turn missing soc-min into a global lower bound.""" + start = datetime(2023, 5, 18, tzinfo=pytz.utc) + end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc) + resolution = timedelta(hours=1) + soc_at_start = 1.0 + soc_max = 10 + soc_min = None + + soc_minima = initialize_series(np.nan, start, end, resolution) + soc_minima[start + resolution] = 4 + + storage_device_constraints = add_storage_constraints( + start, + end, + resolution, + soc_at_start, + soc_targets=None, + soc_maxima=None, + soc_minima=soc_minima, + soc_max=soc_max, + soc_min=soc_min, + ) + + assert storage_device_constraints["min"].notna().sum() == 1 + assert storage_device_constraints["min"].dropna().iloc[0] == 3 + assert storage_device_constraints["min"].isna().any() + assert ( + validate_storage_constraints( + storage_device_constraints, + soc_at_start=soc_at_start, + soc_min=soc_min, + soc_max=soc_max, + resolution=resolution, + ) + == [] + ) + + @pytest.mark.parametrize( "value_min1, value_equals1, value_max1, value_min2, value_equals2, value_max2, expected_constraint_type_violations", [ From f2f29147c97e0627c951283d291929961f8c92ab Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 4 Jun 2026 01:11:13 +0100 Subject: [PATCH 03/13] docs: clarify optional storage soc-min Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/scheduling/metadata.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 2aaa23b9a1..c614dd497e 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -229,7 +229,8 @@ def to_dict(self): example="kWh", ) SOC_MIN = MetaData( - description="""A constant and non-negotiable lower boundary for all values in the schedule (for storage devices, this defaults to 0). + description="""A constant and non-negotiable lower boundary for all values in the schedule. +If omitted, no global lower boundary is applied unless one is inherited from the asset's flex-model. If used, this is regarded as an unsurpassable physical limitation. To set softer boundaries, use the ``soc-minima`` flex-model field instead together with the ``soc-minima-breach-price`` field in the flex-context. [#quantity_field]_ """, From 6f6e584de832cc63a94742fb89b6e13d52ef1db9 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 4 Jun 2026 01:11:22 +0100 Subject: [PATCH 04/13] docs: add optional storage soc-min changelog Signed-off-by: Mohamed Belhsan Hmida --- documentation/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index c2d79a3685..96b3b779a4 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -21,6 +21,7 @@ Infrastructure / Support Bugfixes ----------- +* Let storage scheduling skip a missing ``soc-min`` as an unconstrained lower bound [see `PR #XXXX `_] v0.33.0 | June 1, 2026 From 48869c09fa180a847df03cca3803728e3696ab4b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 4 Jun 2026 02:05:56 +0100 Subject: [PATCH 05/13] docs: update changelog entry with pr number Signed-off-by: Mohamed Belhsan Hmida --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 96b3b779a4..be97123c87 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -21,7 +21,7 @@ Infrastructure / Support Bugfixes ----------- -* Let storage scheduling skip a missing ``soc-min`` as an unconstrained lower bound [see `PR #XXXX `_] +* Let storage scheduling skip a missing ``soc-min`` as an unconstrained lower bound [see `PR #2221 `_] v0.33.0 | June 1, 2026 From 9034b25812c91a8e997c43b1fa01afdcf1609ffe Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:55:22 +0100 Subject: [PATCH 06/13] Update flexmeasures/data/schemas/scheduling/metadata.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> --- flexmeasures/data/schemas/scheduling/metadata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index c614dd497e..7cc8d2aad3 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -229,8 +229,8 @@ def to_dict(self): example="kWh", ) SOC_MIN = MetaData( - description="""A constant and non-negotiable lower boundary for all values in the schedule. -If omitted, no global lower boundary is applied unless one is inherited from the asset's flex-model. + description="""A constant and non-negotiable lower boundary for all SoC values in the schedule. +If omitted, no lower boundary is applied. If used, this is regarded as an unsurpassable physical limitation. To set softer boundaries, use the ``soc-minima`` flex-model field instead together with the ``soc-minima-breach-price`` field in the flex-context. [#quantity_field]_ """, From 3c718c602c720012c3cce736e719901ea00200f0 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:55:35 +0100 Subject: [PATCH 07/13] Update flexmeasures/data/models/planning/tests/test_solver.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> --- flexmeasures/data/models/planning/tests/test_solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 66fcca6ab0..2ee88f9468 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -966,7 +966,7 @@ def test_add_storage_constraints_skips_global_minimum_when_soc_min_is_missing(): ) -def test_add_storage_constraints_with_soc_minima_and_missing_soc_min_has_gaps(): +def test_add_storage_constraints_with_soc_min_missing_and_soc_minima_has_gaps(): """Timed minima should not turn missing soc-min into a global lower bound.""" start = datetime(2023, 5, 18, tzinfo=pytz.utc) end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc) From d170d74a1d97662e61a3791c40d6b1cdb46ea12c Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:55:44 +0100 Subject: [PATCH 08/13] Update flexmeasures/data/models/planning/tests/test_solver.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> --- flexmeasures/data/models/planning/tests/test_solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 2ee88f9468..6009413a0e 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -967,7 +967,7 @@ def test_add_storage_constraints_skips_global_minimum_when_soc_min_is_missing(): def test_add_storage_constraints_with_soc_min_missing_and_soc_minima_has_gaps(): - """Timed minima should not turn missing soc-min into a global lower bound.""" + """Timed minima should not turn missing soc-minima into a global lower bound.""" start = datetime(2023, 5, 18, tzinfo=pytz.utc) end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc) resolution = timedelta(hours=1) From 691ac1ae52677215e843b52ed50b7eb3db42be92 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 4 Jun 2026 22:13:12 +0100 Subject: [PATCH 09/13] fix: allow storage constraints without soc-max Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/models/planning/storage.py | 54 +++++++++++--------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 44829c3470..2663f7f58a 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1427,8 +1427,8 @@ def get_min_max_soc_from_asset(self) -> tuple[str | None, str | None]: def ensure_soc_min_max(self): """ - Make sure we have min and max SOC. - If not passed directly, then get default from asset or targets. + Fill in min and max SOC where fallbacks are available. + If not passed directly, then get defaults from asset or targets. This happens before deserializing the flex-model. """ soc_min_asset, soc_max_asset = self.get_min_max_soc_from_asset() @@ -1438,16 +1438,15 @@ def ensure_soc_min_max(self): else: self.flex_model.pop("soc-min", None) if "soc-max" not in self.flex_model or self.flex_model["soc-max"] is None: - self.flex_model["soc-max"] = soc_max_asset - # Lacking information about the battery's nominal capacity, we use the highest target value as the maximum state of charge - if self.flex_model["soc-max"] is None: + if soc_max_asset is not None: + self.flex_model["soc-max"] = soc_max_asset + else: + # Lacking information about the battery's nominal capacity, we use the highest target value as the maximum state of charge _, max_target = self.get_min_max_targets() - if max_target: + if max_target is not None: self.flex_model["soc-max"] = max_target else: - raise ValueError( - "Need maximal permitted state of charge, please specify soc-max or some soc-targets." - ) + self.flex_model.pop("soc-max", None) def _get_device_power_capacity( self, flex_model: list[dict], assets: list[Asset] @@ -2034,7 +2033,7 @@ def add_storage_constraints( soc_targets: list[dict[str, datetime | float]] | pd.Series | None, soc_maxima: list[dict[str, datetime | float]] | pd.Series | None, soc_minima: list[dict[str, datetime | float]] | pd.Series | None, - soc_max: float, + soc_max: float | None, soc_min: float | None, ) -> pd.DataFrame: """Collect all constraints for a given storage device in a DataFrame that the device_scheduler can interpret. @@ -2046,7 +2045,7 @@ def add_storage_constraints( :param soc_targets: Exact targets for the state of charge at each time. :param soc_maxima: Maximum state of charge at each time. :param soc_minima: Minimum state of charge at each time. - :param soc_max: Maximum state of charge at all times. + :param soc_max: Maximum state of charge at all times, if configured. :param soc_min: Minimum state of charge at all times, if configured. :returns: Constraints (StorageScheduler.COLUMNS) for a storage device, at each time step (index). See device_scheduler for possible column names. @@ -2069,7 +2068,11 @@ def add_storage_constraints( if soc_min is not None else None ) - soc_max_change = (soc_max - soc_at_start) * timedelta(hours=1) / resolution + soc_max_change = ( + (soc_max - soc_at_start) * timedelta(hours=1) / resolution + if soc_max is not None + else None + ) if soc_minima is not None: storage_device_constraints["min"] = build_device_soc_values( @@ -2095,11 +2098,13 @@ def add_storage_constraints( resolution, ) - storage_device_constraints["max"] = ( - storage_device_constraints["max"].astype(float).fillna(soc_max_change) - ) + storage_device_constraints["max"] = storage_device_constraints["max"].astype(float) + if soc_max_change is not None: + storage_device_constraints["max"] = storage_device_constraints["max"].fillna( + soc_max_change + ) - # limiting max and min to be in the range [soc_min, soc_max] + # Limit max and min to the constant bounds that are configured. storage_device_constraints["min"] = ( storage_device_constraints["min"].clip( lower=soc_min_change, upper=soc_max_change @@ -2118,7 +2123,7 @@ def validate_storage_constraints( constraints: pd.DataFrame, soc_at_start: float, soc_min: float | None, - soc_max: float, + soc_max: float | None, resolution: timedelta, ) -> list[dict]: """Check that the storage constraints are fulfilled, e.g min <= equals <= max. @@ -2141,7 +2146,7 @@ def validate_storage_constraints( :param constraints: dataframe containing the constraints of a storage device :param soc_at_start: State of charge at the start time. :param soc_min: Minimum state of charge at all times, if configured. - :param soc_max: Maximum state of charge at all times. + :param soc_max: Maximum state of charge at all times, if configured. :param resolution: Constant duration between the start of each time step. :returns: List of constraint violations, specifying their time, constraint and violation. """ @@ -2174,11 +2179,14 @@ def validate_storage_constraints( soc_min = np.nan # 2) max <= soc_max - soc_max = (soc_max - soc_at_start) * timedelta(hours=1) / resolution - _constraints["soc_max(t)"] = soc_max - constraint_violations += validate_constraint( - _constraints, "max(t)", "<=", "soc_max(t)" - ) + if soc_max is not None: + soc_max = (soc_max - soc_at_start) * timedelta(hours=1) / resolution + _constraints["soc_max(t)"] = soc_max + constraint_violations += validate_constraint( + _constraints, "max(t)", "<=", "soc_max(t)" + ) + else: + soc_max = np.nan ######################################## # B. Validation in the same time frame # From b9a0f2baa8f8dd116561a8f43194859001ff2589 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 4 Jun 2026 22:13:18 +0100 Subject: [PATCH 10/13] test: cover missing storage soc-max Signed-off-by: Mohamed Belhsan Hmida --- .../data/models/planning/tests/test_solver.py | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 6009413a0e..d7b2120139 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -966,6 +966,76 @@ def test_add_storage_constraints_skips_global_minimum_when_soc_min_is_missing(): ) +def test_add_storage_constraints_skips_global_maximum_when_soc_max_is_missing(): + """Missing soc-max should not imply a constant upper bound.""" + start = datetime(2023, 5, 18, tzinfo=pytz.utc) + end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc) + resolution = timedelta(hours=1) + soc_at_start = 0.0 + soc_min = 0 + soc_max = None + + storage_device_constraints = add_storage_constraints( + start, + end, + resolution, + soc_at_start, + soc_targets=None, + soc_maxima=None, + soc_minima=None, + soc_max=soc_max, + soc_min=soc_min, + ) + + assert (storage_device_constraints["min"] == soc_min).all() + assert storage_device_constraints["max"].isna().all() + assert ( + validate_storage_constraints( + storage_device_constraints, + soc_at_start=soc_at_start, + soc_min=soc_min, + soc_max=soc_max, + resolution=resolution, + ) + == [] + ) + + +def test_add_storage_constraints_skips_bounds_when_soc_min_and_soc_max_are_missing(): + """Missing soc-min and soc-max should not imply constant bounds.""" + start = datetime(2023, 5, 18, tzinfo=pytz.utc) + end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc) + resolution = timedelta(hours=1) + soc_at_start = 0.0 + soc_min = None + soc_max = None + + storage_device_constraints = add_storage_constraints( + start, + end, + resolution, + soc_at_start, + soc_targets=None, + soc_maxima=None, + soc_minima=None, + soc_max=soc_max, + soc_min=soc_min, + ) + + assert storage_device_constraints["min"].isna().all() + assert storage_device_constraints["max"].isna().all() + assert ( + validate_storage_constraints( + storage_device_constraints, + soc_at_start=soc_at_start, + soc_min=soc_min, + soc_max=soc_max, + resolution=resolution, + ) + == [] + ) + + def test_add_storage_constraints_with_soc_min_missing_and_soc_minima_has_gaps(): """Timed minima should not turn missing soc-minima into a global lower bound.""" start = datetime(2023, 5, 18, tzinfo=pytz.utc) @@ -1005,6 +1075,69 @@ def test_add_storage_constraints_with_soc_min_missing_and_soc_minima_has_gaps(): ) +def test_add_storage_constraints_with_soc_max_missing_and_soc_maxima_has_gaps(): + """Timed maxima should not turn missing soc-max into a constant upper bound.""" + start = datetime(2023, 5, 18, tzinfo=pytz.utc) + end = datetime(2023, 5, 18, 5, tzinfo=pytz.utc) + resolution = timedelta(hours=1) + soc_at_start = 1.0 + soc_min = 0 + soc_max = None + + soc_maxima = initialize_series(np.nan, start, end, resolution) + soc_maxima[start + resolution] = 6 + + storage_device_constraints = add_storage_constraints( + start, + end, + resolution, + soc_at_start, + soc_targets=None, + soc_maxima=soc_maxima, + soc_minima=None, + soc_max=soc_max, + soc_min=soc_min, + ) + + assert storage_device_constraints["max"].notna().sum() == 1 + assert storage_device_constraints["max"].dropna().iloc[0] == 5 + assert storage_device_constraints["max"].isna().any() + assert ( + validate_storage_constraints( + storage_device_constraints, + soc_at_start=soc_at_start, + soc_min=soc_min, + soc_max=soc_max, + resolution=resolution, + ) + == [] + ) + + +def test_ensure_soc_min_max_allows_missing_soc_max(): + """Missing soc-max should remain unset when no fallback is available.""" + scheduler = StorageScheduler.__new__(StorageScheduler) + scheduler.flex_model = {"soc-min": "0 MWh"} + scheduler.asset = None + scheduler.sensor = None + + scheduler.ensure_soc_min_max() + + assert scheduler.flex_model == {"soc-min": "0 MWh"} + + +def test_ensure_soc_min_max_allows_missing_soc_min_and_soc_max(): + """Missing soc-min and soc-max should remain unset when no fallback is available.""" + scheduler = StorageScheduler.__new__(StorageScheduler) + scheduler.flex_model = {} + scheduler.asset = None + scheduler.sensor = None + + scheduler.ensure_soc_min_max() + + assert scheduler.flex_model == {} + + @pytest.mark.parametrize( "value_min1, value_equals1, value_max1, value_min2, value_equals2, value_max2, expected_constraint_type_violations", [ From ce71ff7782c006d5d0e71b0ef5d02b8aa95662aa Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 4 Jun 2026 22:13:24 +0100 Subject: [PATCH 11/13] docs: describe optional storage soc bounds Signed-off-by: Mohamed Belhsan Hmida --- documentation/changelog.rst | 2 +- flexmeasures/data/schemas/scheduling/metadata.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index be97123c87..c274e978ec 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -21,7 +21,7 @@ Infrastructure / Support Bugfixes ----------- -* Let storage scheduling skip a missing ``soc-min`` as an unconstrained lower bound [see `PR #2221 `_] +* Let storage scheduling treat missing constant SoC bounds as unconstrained lower or upper bounds [see `PR #2221 `_] v0.33.0 | June 1, 2026 diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 7cc8d2aad3..415684b099 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -238,6 +238,7 @@ def to_dict(self): ) SOC_MAX = MetaData( description="""A constant and non-negotiable upper boundary for all values in the schedule (for storage devices, this defaults to max soc-target, if that is provided). +If omitted and no ``soc-target`` is provided, no upper boundary is applied. If used, this is regarded as an unsurpassable physical limitation. To set softer boundaries, use the ``soc-maxima`` flex-model field instead together with the ``soc-maxima-breach-price`` field in the flex-context. [#quantity_field]_ """, From 1b1d630dc14e7e705113688817736e6697369c32 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 17 Jun 2026 16:14:28 +0200 Subject: [PATCH 12/13] delete: ensure_soc_min_max is obsolete; the StorageScheduler runs fine without hard constraints on the SoC Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 41 ------------------- .../data/models/planning/tests/test_solver.py | 24 ----------- .../data/schemas/scheduling/metadata.py | 2 +- 3 files changed, 1 insertion(+), 66 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 2663f7f58a..13e81536cd 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1053,11 +1053,6 @@ def deserialize_flex_config(self): if isinstance(self.flex_model, dict): if self.sensor.generic_asset.asset_type.name in storage_asset_types: self.ensure_soc_at_start() - if ( - self.sensor.generic_asset.asset_type.name in storage_asset_types - or self.has_soc_at_start() - ): - self.ensure_soc_min_max() # Now it's time to check if our flex configuration holds up to schemas self.flex_model = StorageFlexModelSchema( @@ -1069,7 +1064,6 @@ def deserialize_flex_config(self): # Extend schedule period in case a target exceeds its end self.possibly_extend_end(soc_targets=self.flex_model.get("soc_targets")) elif isinstance(self.flex_model, list): - # todo: ensure_soc_min_max in case the device is a storage (see line 847) self.flex_model = MultiSensorFlexModelSchema(many=True).load( self.flex_model ) @@ -1413,41 +1407,6 @@ def get_min_max_targets(self) -> tuple[float | None, float | None]: ) return min_target, max_target - def get_min_max_soc_from_asset(self) -> tuple[str | None, str | None]: - """This happens before deserializing the flex-model.""" - if self.asset is not None: - return self.asset.flex_model.get("soc-min"), self.asset.flex_model.get( - "soc-max" - ) - if self.sensor is not None: - return self.sensor.generic_asset.flex_model.get( - "soc-min" - ), self.sensor.generic_asset.flex_model.get("soc-max") - return None, None - - def ensure_soc_min_max(self): - """ - Fill in min and max SOC where fallbacks are available. - If not passed directly, then get defaults from asset or targets. - This happens before deserializing the flex-model. - """ - soc_min_asset, soc_max_asset = self.get_min_max_soc_from_asset() - if "soc-min" not in self.flex_model or self.flex_model["soc-min"] is None: - if soc_min_asset is not None: - self.flex_model["soc-min"] = soc_min_asset - else: - self.flex_model.pop("soc-min", None) - if "soc-max" not in self.flex_model or self.flex_model["soc-max"] is None: - if soc_max_asset is not None: - self.flex_model["soc-max"] = soc_max_asset - else: - # Lacking information about the battery's nominal capacity, we use the highest target value as the maximum state of charge - _, max_target = self.get_min_max_targets() - if max_target is not None: - self.flex_model["soc-max"] = max_target - else: - self.flex_model.pop("soc-max", None) - def _get_device_power_capacity( self, flex_model: list[dict], assets: list[Asset] ) -> list[ur.Quantity]: diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index d7b2120139..769009c03c 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1114,30 +1114,6 @@ def test_add_storage_constraints_with_soc_max_missing_and_soc_maxima_has_gaps(): ) -def test_ensure_soc_min_max_allows_missing_soc_max(): - """Missing soc-max should remain unset when no fallback is available.""" - scheduler = StorageScheduler.__new__(StorageScheduler) - scheduler.flex_model = {"soc-min": "0 MWh"} - scheduler.asset = None - scheduler.sensor = None - - scheduler.ensure_soc_min_max() - - assert scheduler.flex_model == {"soc-min": "0 MWh"} - - -def test_ensure_soc_min_max_allows_missing_soc_min_and_soc_max(): - """Missing soc-min and soc-max should remain unset when no fallback is available.""" - scheduler = StorageScheduler.__new__(StorageScheduler) - scheduler.flex_model = {} - scheduler.asset = None - scheduler.sensor = None - - scheduler.ensure_soc_min_max() - - assert scheduler.flex_model == {} - - @pytest.mark.parametrize( "value_min1, value_equals1, value_max1, value_min2, value_equals2, value_max2, expected_constraint_type_violations", [ diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 415684b099..6f92bff5cc 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -238,7 +238,7 @@ def to_dict(self): ) SOC_MAX = MetaData( description="""A constant and non-negotiable upper boundary for all values in the schedule (for storage devices, this defaults to max soc-target, if that is provided). -If omitted and no ``soc-target`` is provided, no upper boundary is applied. +If omitted, no upper boundary is applied. If used, this is regarded as an unsurpassable physical limitation. To set softer boundaries, use the ``soc-maxima`` flex-model field instead together with the ``soc-maxima-breach-price`` field in the flex-context. [#quantity_field]_ """, From 2c00994548b222f46fafdfc97b232af9fb8f1a74 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 17 Jun 2026 16:17:24 +0200 Subject: [PATCH 13/13] chore: update openapi-specs.json Signed-off-by: F.N. Claessen --- flexmeasures/ui/static/openapi-specs.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 3f122566b7..bd0fd2b386 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6085,12 +6085,12 @@ "soc-min": { "type": "string", "x-minimum": "0 MWh", - "description": "A constant and non-negotiable lower boundary for all values in the schedule (for storage devices, this defaults to 0).\nIf used, this is regarded as an unsurpassable physical limitation.\nTo set softer boundaries, use the soc-minima flex-model field instead together with the soc-minima-breach-price field in the flex-context.\n", + "description": "A constant and non-negotiable lower boundary for all SoC values in the schedule.\nIf omitted, no lower boundary is applied.\nIf used, this is regarded as an unsurpassable physical limitation.\nTo set softer boundaries, use the soc-minima flex-model field instead together with the soc-minima-breach-price field in the flex-context.\n", "example": "2.5 kWh" }, "soc-max": { "type": "string", - "description": "A constant and non-negotiable upper boundary for all values in the schedule (for storage devices, this defaults to max soc-target, if that is provided).\nIf used, this is regarded as an unsurpassable physical limitation.\nTo set softer boundaries, use the soc-maxima flex-model field instead together with the soc-maxima-breach-price field in the flex-context.\n", + "description": "A constant and non-negotiable upper boundary for all values in the schedule (for storage devices, this defaults to max soc-target, if that is provided).\nIf omitted, no upper boundary is applied.\nIf used, this is regarded as an unsurpassable physical limitation.\nTo set softer boundaries, use the soc-maxima flex-model field instead together with the soc-maxima-breach-price field in the flex-context.\n", "example": "7 kWh" }, "power-capacity": {