Skip to content

Commit

Permalink
Merge a548145 into aecb395
Browse files Browse the repository at this point in the history
  • Loading branch information
victorgarcia98 committed May 18, 2023
2 parents aecb395 + a548145 commit e24f6c4
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 30 deletions.
101 changes: 74 additions & 27 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
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 @@ -134,20 +136,51 @@ def compute(
if soc_targets is not None:
# make an equality series with the SOC targets set in the flex model
# device_constraints[0] refers to the flexible device we are scheduling
device_constraints[0]["equals"] = build_device_soc_targets(
device_constraints[0]["equals"] = build_device_soc_values(
soc_targets,
soc_at_start,
start,
end,
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_values(
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_values(
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]
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, "0.14")
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
Original file line number Diff line number Diff line change
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
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

# test for soc_maxima
# check that the local maximum constraint is respected
assert soc_schedule2.loc[datetime(2015, 1, 2, 14)] <= 1.0

# 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
Original file line number Diff line number Diff line change
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

0 comments on commit e24f6c4

Please sign in to comment.