Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(scheduling): Add multiple maxima and minima constraints into StorageScheduler #680

Merged
merged 19 commits into from May 29, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
43cf12e
fix: deprecated decorator wasn't returning the value of the moved cal…
victorgarcia98 May 11, 2023
583bd28
fix: update test to check that the decorator is returning
victorgarcia98 May 11, 2023
3e0744c
feat: add SOCValueSchema
victorgarcia98 May 11, 2023
e88fa69
feat: pass soc_maxima and soc_minima to device_scheduler
victorgarcia98 May 11, 2023
633142d
feat: add test test_soc_bounds_timeseries
victorgarcia98 May 11, 2023
10de976
test: check for inequality rather than equality constraints
victorgarcia98 May 17, 2023
db317f7
Merge branch 'main' into max_min_soc_timeseries
victorgarcia98 May 18, 2023
ec60079
fix: correcting sunset version
victorgarcia98 May 18, 2023
121231a
refactor: use new function
victorgarcia98 May 18, 2023
a548145
refactor: finishing refactor (I forgot two calls).
victorgarcia98 May 18, 2023
ac70366
docs: update the endpoint trigger_schedule with an example on how to …
victorgarcia98 May 23, 2023
0211978
feat: add constraint validation
victorgarcia98 May 23, 2023
3f4df10
Merge branch 'main' into max_min_soc_timeseries
victorgarcia98 May 23, 2023
eb6f9b7
docs: add chagelog entry
victorgarcia98 May 23, 2023
fb36f49
Review fixes max min soc timeseries (#700)
Flix6x May 25, 2023
9aed98f
refactor: Constraint validation for cases A and B (#702)
Flix6x May 26, 2023
70cbb3e
style: remove left-over print
victorgarcia98 May 29, 2023
e3807ed
fix: escape backslash in regex
victorgarcia98 May 29, 2023
f2ee39e
Merge branch 'main' into max_min_soc_timeseries
victorgarcia98 May 29, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
99 changes: 73 additions & 26 deletions flexmeasures/data/models/planning/storage.py
Expand Up @@ -55,6 +55,8 @@ def compute(
soc_targets = self.flex_model.get("soc_targets")
soc_min = self.flex_model.get("soc_min")
soc_max = self.flex_model.get("soc_max")
soc_maxima = self.flex_model.get("soc_maxima")
soc_minima = self.flex_model.get("soc_minima")
roundtrip_efficiency = self.flex_model.get("roundtrip_efficiency")
prefer_charging_sooner = self.flex_model.get("prefer_charging_sooner", True)

Expand Down Expand Up @@ -142,12 +144,43 @@ def compute(
resolution,
)

device_constraints[0]["min"] = (soc_min - soc_at_start) * (
timedelta(hours=1) / 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

if soc_minima is not None:
device_constraints[0]["min"] = build_device_soc_targets(
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved
soc_minima,
soc_at_start,
start,
end,
resolution,
)

device_constraints[0]["min"] = device_constraints[0]["min"].fillna(
soc_min_change
)

if soc_maxima is not None:
device_constraints[0]["max"] = build_device_soc_targets(
soc_maxima,
soc_at_start,
start,
end,
resolution,
)

device_constraints[0]["max"] = device_constraints[0]["max"].fillna(
soc_max_change
)
device_constraints[0]["max"] = (soc_max - soc_at_start) * (
timedelta(hours=1) / resolution

# limiting max and min to be in the range [soc_min, soc_max]
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved
device_constraints[0]["min"] = device_constraints[0]["min"].clip(
lower=soc_min_change, upper=soc_max_change
)
device_constraints[0]["max"] = device_constraints[0]["max"].clip(
lower=soc_min_change, upper=soc_max_change
)

if sensor.get_attribute("is_strictly_non_positive"):
device_constraints[0]["derivative min"] = 0
else:
Expand Down Expand Up @@ -347,19 +380,19 @@ def ensure_soc_min_max(self):
)


def build_device_soc_targets(
targets: List[Dict[str, datetime | float]] | pd.Series,
def build_device_soc_values(
soc_values: List[Dict[str, datetime | float]] | pd.Series,
soc_at_start: float,
start_of_schedule: datetime,
end_of_schedule: datetime,
resolution: timedelta,
) -> pd.Series:
"""
Utility function to create a Pandas series from SOC targets we got from the flex-model.
Utility function to create a Pandas series from SOC values we got from the flex-model.

Should set NaN anywhere where there is no target.

Target SOC values should be indexed by their due date. For example, for quarter-hourly targets between 5 and 6 AM:
SOC values should be indexed by their due date. For example, for quarter-hourly targets between 5 and 6 AM:
>>> df = pd.Series(data=[1, 2, 2.5, 3], index=pd.date_range(datetime(2010,1,1,5), datetime(2010,1,1,6), freq=timedelta(minutes=15), inclusive="right"))
>>> print(df)
2010-01-01 05:15:00 1.0
Expand All @@ -368,50 +401,64 @@ def build_device_soc_targets(
2010-01-01 06:00:00 3.0
Freq: 15T, dtype: float64

TODO: this function could become the deserialization method of a new SOCTargetsSchema (targets, plural), which wraps SOCTargetSchema.
TODO: this function could become the deserialization method of a new SOCValueSchema (targets, plural), which wraps SOCValueSchema.

"""
if isinstance(targets, pd.Series): # some tests prepare it this way
device_targets = targets
if isinstance(soc_values, pd.Series): # some tests prepare it this way
device_values = soc_values
else:
device_targets = initialize_series(
device_values = initialize_series(
np.nan,
start=start_of_schedule,
end=end_of_schedule,
resolution=resolution,
inclusive="right", # note that target values are indexed by their due date (i.e. inclusive="right")
)

for target in targets:
target_value = target["value"]
target_datetime = target["datetime"].astimezone(
device_targets.index.tzinfo
for soc_value in soc_values:
soc = soc_value["value"]
soc_datetime = soc_value["datetime"].astimezone(
device_values.index.tzinfo
) # otherwise DST would be problematic
if target_datetime > end_of_schedule:
if soc_datetime > end_of_schedule:
# Skip too-far-into-the-future target
max_server_horizon = get_max_planning_horizon(resolution)
current_app.logger.warning(
f"Disregarding target datetime {target_datetime}, because it exceeds {end_of_schedule}. Maximum scheduling horizon is {max_server_horizon}."
f"Disregarding target datetime {soc_datetime}, because it exceeds {end_of_schedule}. Maximum scheduling horizon is {max_server_horizon}."
)
continue

device_targets.loc[target_datetime] = target_value
device_values.loc[soc_datetime] = soc

# soc targets are at the end of each time slot, while prices are indexed by the start of each time slot
device_targets = device_targets[
start_of_schedule + resolution : end_of_schedule
]
# soc_values are at the end of each time slot, while prices are indexed by the start of each time slot
device_values = device_values[start_of_schedule + resolution : end_of_schedule]

device_targets = device_targets.tz_convert("UTC")
device_values = device_values.tz_convert("UTC")

# shift "equals" constraint for target SOC by one resolution (the target defines a state at a certain time,
# while the "equals" constraint defines what the total stock should be at the end of a time slot,
# where the time slot is indexed by its starting time)
device_targets = device_targets.shift(-1, freq=resolution).values * (
device_values = device_values.shift(-1, freq=resolution).values * (
timedelta(hours=1) / resolution
) - soc_at_start * (timedelta(hours=1) / resolution)

return device_targets
return device_values


#####################
# TO BE DEPRECATED #
####################
@deprecated(build_device_soc_values, "14")
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved
def build_device_soc_targets(
targets: List[Dict[str, datetime | float]] | pd.Series,
soc_at_start: float,
start_of_schedule: datetime,
end_of_schedule: datetime,
resolution: timedelta,
) -> pd.Series:
return build_device_soc_values(
targets, soc_at_start, start_of_schedule, end_of_schedule, resolution
)


StorageScheduler.compute_schedule = deprecated(StorageScheduler.compute, "0.14")(
Expand Down
97 changes: 97 additions & 0 deletions flexmeasures/data/models/planning/tests/test_solver.py
Expand Up @@ -421,3 +421,100 @@ def test_building_solver_day_2(
assert soc_schedule.iloc[-1] == max(
soc_min, battery.get_attribute("min_soc_in_mwh")
)


def test_soc_bounds_timeseries(add_battery_assets):
"""Check that the maxima and minima timeseries alter the result
of the optimization.

Two schedules are run:
- with global maximum and minimum values
- with global maximum and minimum values + maxima / minima time series constraints
"""

# get the sensors from the database
epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none()
battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none()
assert battery.get_attribute("market_id") == epex_da.id

# time paramaters
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved
tz = pytz.timezone("Europe/Amsterdam")
start = tz.localize(datetime(2015, 1, 2))
end = tz.localize(datetime(2015, 1, 3))
resolution = timedelta(hours=1)

# soc parameters
soc_at_start = battery.get_attribute("soc_in_mwh")
soc_min = 0.5
soc_max = 4.5

def compute_schedule(flex_model):
scheduler = StorageScheduler(
battery,
start,
end,
resolution,
flex_model=flex_model,
)
schedule = scheduler.compute()

soc_schedule = integrate_time_series(
schedule,
soc_at_start,
decimal_precision=6,
)

return soc_schedule

flex_model = {
"soc-at-start": soc_at_start,
"soc-min": soc_min,
"soc-max": soc_max,
}

soc_schedule1 = compute_schedule(flex_model)

# soc maxima and soc minima
soc_maxima = [
{"datetime": "2015-01-02T15:00:00+01:00", "value": 1.0},
{"datetime": "2015-01-02T16:00:00+01:00", "value": 1.0},
]

soc_minima = [{"datetime": "2015-01-02T08:00:00+01:00", "value": 3.5}]

soc_targets = [{"datetime": "2015-01-02T19:00:00+01:00", "value": 2.0}]

flex_model = {
"soc-at-start": soc_at_start,
"soc-min": soc_min,
"soc-max": soc_max,
"soc-maxima": soc_maxima,
"soc-minima": soc_minima,
"soc-targets": soc_targets,
}

soc_schedule2 = compute_schedule(flex_model)

# check that, in this case, adding the constraints
# alter the SOC profile
assert not soc_schedule2.equals(soc_schedule1)

# check that global minimum is achieved
assert soc_schedule1.min() == soc_min
assert soc_schedule2.min() == soc_min

# check that global maximum is achieved
assert soc_schedule1.max() == soc_max
assert soc_schedule2.max() == soc_max

# test for soc_minima
# check that the local minimum constraint is respected
assert soc_schedule2.loc[datetime(2015, 1, 2, 7)] == 3.5
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved

# test for soc_maxima
# check that the local maximum constraint is respected
assert soc_schedule2.loc[datetime(2015, 1, 2, 14)] == 1.0
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved

# test for soc_targets
# check that the SOC target (at 19 pm, local time) is met
assert soc_schedule2.loc[datetime(2015, 1, 2, 18)] == 2.0
31 changes: 28 additions & 3 deletions flexmeasures/data/schemas/scheduling/storage.py
Expand Up @@ -3,7 +3,14 @@
from datetime import datetime

from flask import current_app
from marshmallow import Schema, post_load, validate, validates_schema, fields
from marshmallow import (
Schema,
post_load,
validate,
validates_schema,
fields,
validates,
)
from marshmallow.validate import OneOf

from flexmeasures.data.models.time_series import Sensor
Expand All @@ -12,14 +19,24 @@
from flexmeasures.utils.unit_utils import ur


class SOCTargetSchema(Schema):
class SOCValueSchema(Schema):
"""
A point in time with a target value.
"""

value = fields.Float(required=True)
datetime = AwareDateTimeField(required=True)

def __init__(self, *args, **kwargs):
self.value_validator = kwargs.pop("value_validator", None)
super().__init__(*args, **kwargs)

@validates("value")
def validate_value(self, _value):

if self.value_validator is not None:
self.value_validator(_value)


class StorageFlexModelSchema(Schema):
"""
Expand All @@ -29,8 +46,16 @@ class StorageFlexModelSchema(Schema):
"""

soc_at_start = fields.Float(required=True, data_key="soc-at-start")

soc_min = fields.Float(validate=validate.Range(min=0), data_key="soc-min")
soc_max = fields.Float(data_key="soc-max")

soc_maxima = fields.List(fields.Nested(SOCValueSchema()), data_key="soc-maxima")
soc_minima = fields.List(
fields.Nested(SOCValueSchema(value_validator=validate.Range(min=0))),
data_key="soc-minima",
)

soc_unit = fields.Str(
validate=OneOf(
[
Expand All @@ -40,7 +65,7 @@ class StorageFlexModelSchema(Schema):
),
data_key="soc-unit",
) # todo: allow unit to be set per field, using QuantityField("%", validate=validate.Range(min=0, max=1))
soc_targets = fields.List(fields.Nested(SOCTargetSchema()), data_key="soc-targets")
soc_targets = fields.List(fields.Nested(SOCValueSchema()), data_key="soc-targets")
roundtrip_efficiency = QuantityField(
"%",
validate=validate.Range(min=0, max=1, min_inclusive=False, max_inclusive=True),
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/utils/coding_utils.py
Expand Up @@ -166,7 +166,7 @@ def wrapper(*args, **kwargs):
f"The method or function {func.__name__} is deprecated and it is expected to be sunset in version {version}. Please, switch to using {inspect.getmodule(alternative).__name__}:{alternative.__name__} to suppress this warning."
)

func(*args, **kwargs)
return func(*args, **kwargs)

return wrapper

Expand Down
10 changes: 7 additions & 3 deletions flexmeasures/utils/tests/test_coding_utils.py
Expand Up @@ -2,19 +2,19 @@


def other_function():
pass
return 1


def test_deprecated_decorator(caplog, app):

# defining a function that is deprecated
@deprecated(other_function, "v14")
def deprecated_function():
pass
return other_function()

caplog.clear()

deprecated_function() # calling a deprecated function
value = deprecated_function() # calling a deprecated function
print(caplog.records)
assert len(caplog.records) == 1 # only 1 warning being printed

Expand All @@ -25,3 +25,7 @@ def deprecated_function():
assert "v14" in str(
caplog.records[0].message
) # checking that the message is correct

assert (
value == 1
) # check that the decorator is returning the value of `other_function`