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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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]_ """,