diff --git a/documentation/changelog.rst b/documentation/changelog.rst index b91b551e28..6e8d585933 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -24,6 +24,7 @@ Infrastructure / Support Bugfixes ----------- +* Let storage scheduling treat missing constant SoC bounds as unconstrained lower or upper bounds [see `PR #2221 `_] * Allow root assets belonging to different accounts to share the same name, while keeping asset names unique among root assets within the same account and among children of the same parent [see `PR #2226 `_] diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index eceebd8082..4c59a0ffa9 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1063,11 +1063,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( @@ -1079,7 +1074,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 ) @@ -1423,40 +1417,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): - """ - Make sure we have min and max SOC. - If not passed directly, then get default 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: - # 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-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: - _, max_target = self.get_min_max_targets() - if max_target: - self.flex_model["soc-max"] = max_target - else: - raise ValueError( - "Need maximal permitted state of charge, please specify soc-max or some soc-targets." - ) - def _get_device_power_capacity( self, flex_model: list[dict], @@ -2144,8 +2104,8 @@ 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_min: 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. @@ -2156,8 +2116,8 @@ 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_min: Minimum 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. """ @@ -2174,8 +2134,16 @@ 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_max_change = (soc_max - 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_max is not None + else None + ) if soc_minima is not None: storage_device_constraints["min"] = build_device_soc_values( @@ -2186,9 +2154,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( @@ -2199,13 +2169,19 @@ 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] - storage_device_constraints["min"] = storage_device_constraints["min"].clip( - lower=soc_min_change, upper=soc_max_change + # 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 + ) + 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 @@ -2217,8 +2193,8 @@ def add_storage_constraints( def validate_storage_constraints( constraints: pd.DataFrame, soc_at_start: float, - soc_min: float, - soc_max: float, + soc_min: float | None, + soc_max: float | None, resolution: timedelta, ) -> list[dict]: """Check that the storage constraints are fulfilled, e.g min <= equals <= max. @@ -2240,8 +2216,8 @@ 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_max: Maximum 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, if configured. :param resolution: Constant duration between the start of each time step. :returns: List of constraint violations, specifying their time, constraint and violation. """ @@ -2264,18 +2240,24 @@ 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 - _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 # diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 8bd88d2a68..e9e61da848 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -931,6 +931,189 @@ 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_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) + 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, + ) + == [] + ) + + +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, + ) + == [] + ) + + @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 aa2039edfe..21c7765f18 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 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]_ """, @@ -237,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, 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]_ """, diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6851221a71..fd62b861ac 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6086,12 +6086,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": {