Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Infrastructure / Support

Bugfixes
-----------
* Let storage scheduling treat missing constant SoC bounds as unconstrained lower or upper bounds [see `PR #2221 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2226>`_]


Expand Down
120 changes: 51 additions & 69 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
)
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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.

Expand All @@ -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.
"""
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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.
"""
Expand All @@ -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 #
Expand Down
183 changes: 183 additions & 0 deletions flexmeasures/data/models/planning/tests/test_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
Loading
Loading