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

Issue 450 api option to customize which price and power sensors to take into account for scheduling #451

Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
9cd08cd
Add optional API fields to specify which price sensors to use for con…
Flix6x Jun 24, 2022
7acb199
Rename API fields
Flix6x Jun 24, 2022
51551c0
Add optional API field to specify which inflexible device sensors to …
Flix6x Jun 25, 2022
cdf2055
Add test data for inflexible device
Flix6x Jun 25, 2022
7fd16c3
Add test case for scheduling a battery including an inflexible device
Flix6x Jun 25, 2022
dd2c454
Fix boundary conditions for including inflexible devices
Flix6x Jun 28, 2022
c17bf95
Assume perfect efficiency if no efficiency information is available
Flix6x Jun 28, 2022
dde07db
Switch from random periodic data to a step function, leaving a little…
Flix6x Jun 28, 2022
973bf87
Return test data with conftest setup
Flix6x Jun 28, 2022
96981de
Skip redundant sensor queries in tests
Flix6x Jun 28, 2022
418c186
Refactor: rename variable for clarity
Flix6x Jun 28, 2022
fc7f08a
Take into account the nominal capacity of the asset, if given
Flix6x Jun 28, 2022
ef6d5c0
Refactor: rename variable in test
Flix6x Jun 28, 2022
170be64
Convert consumption/production power sign for our scheduler
Flix6x Jun 30, 2022
116d422
Split fixtures, set up the building with a flexible device and two in…
Flix6x Jun 30, 2022
b04401d
Test building solver
Flix6x Jun 30, 2022
e9b3338
Merge branch 'main' into Issue-450_API_option_to_customize_which_pric…
Flix6x Jul 19, 2022
4e90f39
Merge branch 'main' into Issue-450_API_option_to_customize_which_pric…
Flix6x Jul 19, 2022
8c343dc
Fix passing along list of inflexible device sensors for battery sched…
Flix6x Aug 8, 2022
bb01440
Restrict data used for scheduling to what was known at the time of sc…
Flix6x Aug 8, 2022
38ceee6
Fix tests, given that the values for solar PV and residual demand (de…
Flix6x Aug 8, 2022
0c9e886
Let test schedule the future instead of the past, which requires fore…
Flix6x Aug 8, 2022
3a95f86
Set up test data for inflexible devices in the Europe/Amsterdam timez…
Flix6x Aug 8, 2022
38e87c0
Set up test data for EPEX market prices in the Europe/Amsterdam timez…
Flix6x Aug 8, 2022
e0a1507
Fix scheduling tests corresponding to having market prices available …
Flix6x Aug 8, 2022
46969c7
black
Flix6x Aug 8, 2022
9151e94
Add comment from earlier commit
Flix6x Aug 8, 2022
b6bb9ae
Clear up inline comment
Flix6x Aug 8, 2022
e41c0ae
Rename variable internally
Flix6x Aug 8, 2022
fdaaccc
Rename API field
Flix6x Aug 8, 2022
bd54acf
Rename CLI field with future deprecation warning
Flix6x Aug 8, 2022
67aadbd
Clarify that the consumption price is being applied to the aggregate …
Flix6x Aug 8, 2022
be58a3f
Clarify description of aggregate power flow
Flix6x Aug 24, 2022
fcbbc7d
Add return type annotation
Flix6x Aug 24, 2022
d429139
Expand docstring
Flix6x Aug 24, 2022
dd96b66
Fix use of wrong exception
Flix6x Aug 24, 2022
d8b7f57
Refactor: rename util function
Flix6x Aug 24, 2022
5cc79da
Changelog entry
Flix6x Aug 24, 2022
d101e1d
Implement recommended docstring revision
Flix6x Aug 24, 2022
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
26 changes: 24 additions & 2 deletions flexmeasures/api/v3_0/sensors.py
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta
import json
from typing import Optional
from typing import List, Optional

from flask import current_app
from flask_classful import FlaskView, route
Expand Down Expand Up @@ -220,6 +220,15 @@ def get_data(self, response: dict):
), # todo: allow unit to be set per field, using QuantityField("%", validate=validate.Range(min=0, max=1))
"targets": fields.List(fields.Nested(TargetSchema), data_key="soc-targets"),
# todo: add a duration parameter, instead of falling back to FLEXMEASURES_PLANNING_HORIZON
"up_deviation_price_sensor": SensorIdField(
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
data_key="consumption-price-sensor", required=False
),
"down_deviation_price_sensor": SensorIdField(
data_key="feed-in-price-sensor", required=False
),
"inflexible_device_sensors": fields.List(
SensorIdField, data_key="inflexible-device-sensors", required=False
),
},
location="json",
)
Expand All @@ -231,6 +240,9 @@ def trigger_schedule( # noqa: C901
unit: str,
prior: datetime,
roundtrip_efficiency: Optional[ur.Quantity] = None,
up_deviation_price_sensor: Optional[Sensor] = None,
down_deviation_price_sensor: Optional[Sensor] = None,
inflexible_device_sensors: Optional[List[Sensor]] = None,
**kwargs,
):
"""
Expand Down Expand Up @@ -258,6 +270,10 @@ def trigger_schedule( # noqa: C901
with a target state of charge of 25 kWh at 4.00pm.
The minimum and maximum soc are set to 10 and 25 kWh, respectively.
Roundtrip efficiency for use in scheduling is set to 98%.
Consumption should be priced by sensor 9,
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
and feed-in (i.e. production) should be priced by sensor 10,
where the aggregate power flow includes the sum over sensors 13, 14 and 15.
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
Note that, if forecasts for sensors 13, 14 and 15 are not available, a schedule cannot be computed.

.. code-block:: json

Expand All @@ -273,7 +289,10 @@ def trigger_schedule( # noqa: C901
],
"soc-min": 10,
"soc-max": 25,
"roundtrip-efficiency": 0.98
"roundtrip-efficiency": 0.98,
"consumption-price-sensor": 9,
"feed-in-price-sensor": 10,
"inflexible-device-sensors": [13, 14, 15]
}

**Example response**
Expand Down Expand Up @@ -395,6 +414,9 @@ def trigger_schedule( # noqa: C901
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
up_deviation_price_sensor=up_deviation_price_sensor,
down_deviation_price_sensor=down_deviation_price_sensor,
inflexible_device_sensors=inflexible_device_sensors,
enqueue=True,
)

Expand Down
6 changes: 4 additions & 2 deletions flexmeasures/cli/data_add.py
Expand Up @@ -922,7 +922,8 @@ def create_schedule(
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
price_sensor=optimization_context_sensor,
up_deviation_price_sensor=optimization_context_sensor,
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
down_deviation_price_sensor=optimization_context_sensor,
)
if job:
print(f"New scheduling job {job.id} has been added to the queue.")
Expand All @@ -938,7 +939,8 @@ def create_schedule(
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
price_sensor=optimization_context_sensor,
up_deviation_price_sensor=optimization_context_sensor,
down_deviation_price_sensor=optimization_context_sensor,
)
if success:
print("New schedule is stored.")
Expand Down
49 changes: 39 additions & 10 deletions flexmeasures/data/models/planning/battery.py
@@ -1,4 +1,4 @@
from typing import Optional, Union
from typing import List, Optional, Union
from datetime import datetime, timedelta

import pandas as pd
Expand All @@ -10,6 +10,7 @@
initialize_series,
add_tiny_price_slope,
get_prices,
inflexible_device_forecasts,
fallback_charging_policy,
)

Expand All @@ -25,7 +26,9 @@ def schedule_battery(
soc_max: Optional[float] = None,
roundtrip_efficiency: Optional[float] = None,
prefer_charging_sooner: bool = True,
price_sensor: Optional[Sensor] = None,
up_deviation_price_sensor: Optional[Sensor] = None,
down_deviation_price_sensor: Optional[Sensor] = None,
inflexible_device_sensors: Optional[List[Sensor]] = None,
round_to_decimals: Optional[int] = 6,
) -> Union[pd.Series, None]:
"""Schedule a battery asset based directly on the latest beliefs regarding market prices within the specified time
Expand Down Expand Up @@ -56,13 +59,21 @@ def schedule_battery(
soc_max = sensor.get_attribute("max_soc_in_mwh")

# Check for known prices or price forecasts, trimming planning window accordingly
prices, (start, end) = get_prices(
up_deviation_prices, (start, end) = get_prices(
(start, end),
resolution,
price_sensor=price_sensor,
price_sensor=up_deviation_price_sensor,
sensor=sensor,
allow_trimmed_query_window=True,
)
down_deviation_prices, (start, end) = get_prices(
(start, end),
resolution,
price_sensor=down_deviation_price_sensor,
sensor=sensor,
allow_trimmed_query_window=True,
)

start = pd.Timestamp(start).tz_convert("UTC")
end = pd.Timestamp(end).tz_convert("UTC")
if soc_targets is not None:
Expand All @@ -73,20 +84,23 @@ def schedule_battery(
# Add tiny price slope to prefer charging now rather than later, and discharging later rather than now.
# We penalise the future with at most 1 per thousand times the price spread.
if prefer_charging_sooner:
prices = add_tiny_price_slope(prices, "event_value")
up_deviation_prices = add_tiny_price_slope(up_deviation_prices, "event_value")
down_deviation_prices = add_tiny_price_slope(
down_deviation_prices, "event_value"
)

# Set up commitments to optimise for
commitment_quantities = [initialize_series(0, start, end, resolution)]

# Todo: convert to EUR/(deviation of commitment, which is in MW)
commitment_upwards_deviation_price = [
prices.loc[start : end - resolution]["event_value"]
up_deviation_prices.loc[start : end - resolution]["event_value"]
]
commitment_downwards_deviation_price = [
prices.loc[start : end - resolution]["event_value"]
down_deviation_prices.loc[start : end - resolution]["event_value"]
]

# Set up device constraints (only one device for this EMS)
# Set up device constraints (only one flexible device for this EMS, plus inflexible devices)
columns = [
"equals",
"max",
Expand All @@ -97,7 +111,18 @@ def schedule_battery(
"derivative down efficiency",
"derivative up efficiency",
]
device_constraints = [initialize_df(columns, start, end, resolution)]
if inflexible_device_sensors is None:
inflexible_device_sensors = []
device_constraints = [
initialize_df(columns, start, end, resolution)
for i in range(1 + len(inflexible_device_sensors))
]
for i, inflexible_sensor in enumerate(inflexible_device_sensors):
device_constraints[i + 1]["derivative equals"] = inflexible_device_forecasts(
(start, end),
resolution,
inflexible_sensor,
)
if soc_targets is not None:
device_constraints[0]["equals"] = soc_targets.shift(
-1, freq=resolution
Expand All @@ -121,9 +146,13 @@ def schedule_battery(
device_constraints[0]["derivative down efficiency"] = roundtrip_efficiency**0.5
device_constraints[0]["derivative up efficiency"] = roundtrip_efficiency**0.5

# Set up EMS constraints (no additional constraints)
# Set up EMS constraints
columns = ["derivative max", "derivative min"]
ems_constraints = initialize_df(columns, start, end, resolution)
ems_capacity = sensor.generic_asset.get_attribute("capacity_in_mw")
if ems_capacity is not None:
ems_constraints["derivative min"] = ems_capacity * -1
ems_constraints["derivative max"] = ems_capacity

ems_schedule, expected_costs, scheduler_results = device_scheduler(
device_constraints,
Expand Down
48 changes: 38 additions & 10 deletions flexmeasures/data/models/planning/charging_station.py
@@ -1,4 +1,4 @@
from typing import Optional, Union
from typing import List, Optional, Union
from datetime import datetime, timedelta

import pandas as pd
Expand All @@ -10,6 +10,7 @@
initialize_series,
add_tiny_price_slope,
get_prices,
inflexible_device_forecasts,
fallback_charging_policy,
)

Expand All @@ -25,7 +26,9 @@ def schedule_charging_station(
soc_max: Optional[float] = None,
roundtrip_efficiency: Optional[float] = None,
prefer_charging_sooner: bool = True,
price_sensor: Optional[Sensor] = None,
up_deviation_price_sensor: Optional[Sensor] = None,
down_deviation_price_sensor: Optional[Sensor] = None,
inflexible_device_sensors: Optional[List[Sensor]] = None,
round_to_decimals: Optional[int] = 6,
) -> Union[pd.Series, None]:
"""Schedule a charging station asset based directly on the latest beliefs regarding market prices within the specified time
Expand Down Expand Up @@ -53,13 +56,21 @@ def schedule_charging_station(
soc_max = sensor.get_attribute("max_soc_in_mwh", max(soc_targets.values))

# Check for known prices or price forecasts, trimming planning window accordingly
prices, (start, end) = get_prices(
up_deviation_prices, (start, end) = get_prices(
(start, end),
resolution,
price_sensor=price_sensor,
price_sensor=up_deviation_price_sensor,
sensor=sensor,
allow_trimmed_query_window=True,
)
down_deviation_prices, (start, end) = get_prices(
(start, end),
resolution,
price_sensor=down_deviation_price_sensor,
sensor=sensor,
allow_trimmed_query_window=True,
)

# soc targets are at the end of each time slot, while prices are indexed by the start of each time slot
soc_targets = soc_targets.tz_convert("UTC")
start = pd.Timestamp(start).tz_convert("UTC")
Expand All @@ -69,20 +80,23 @@ def schedule_charging_station(
# Add tiny price slope to prefer charging now rather than later, and discharging later rather than now.
# We penalise the future with at most 1 per thousand times the price spread.
if prefer_charging_sooner:
prices = add_tiny_price_slope(prices, "event_value")
up_deviation_prices = add_tiny_price_slope(up_deviation_prices, "event_value")
down_deviation_prices = add_tiny_price_slope(
down_deviation_prices, "event_value"
)

# Set up commitments to optimise for
commitment_quantities = [initialize_series(0, start, end, resolution)]

# Todo: convert to EUR/(deviation of commitment, which is in MW)
commitment_upwards_deviation_price = [
prices.loc[start : end - resolution]["event_value"]
up_deviation_prices.loc[start : end - resolution]["event_value"]
]
commitment_downwards_deviation_price = [
prices.loc[start : end - resolution]["event_value"]
down_deviation_prices.loc[start : end - resolution]["event_value"]
]

# Set up device constraints (only one device for this EMS)
# Set up device constraints (only one flexible device for this EMS, plus inflexible devices)
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
columns = [
"equals",
"max",
Expand All @@ -91,7 +105,17 @@ def schedule_charging_station(
"derivative max",
"derivative min",
]
device_constraints = [initialize_df(columns, start, end, resolution)]
if inflexible_device_sensors is None:
inflexible_device_sensors = []
device_constraints = [initialize_df(columns, start, end, resolution)] * (
1 + len(inflexible_device_sensors)
)
for i, inflexible_sensor in enumerate(inflexible_device_sensors):
device_constraints[i + 1]["derivative equals"] = inflexible_device_forecasts(
(start, end),
resolution,
inflexible_sensor,
)
device_constraints[0]["equals"] = soc_targets.shift(-1, freq=resolution).values * (
timedelta(hours=1) / resolution
) - soc_at_start * (
Expand Down Expand Up @@ -121,9 +145,13 @@ def schedule_charging_station(
device_constraints[0]["derivative down efficiency"] = roundtrip_efficiency**0.5
device_constraints[0]["derivative up efficiency"] = roundtrip_efficiency**0.5

# Set up EMS constraints (no additional constraints)
# Set up EMS constraints
columns = ["derivative max", "derivative min"]
ems_constraints = initialize_df(columns, start, end, resolution)
ems_capacity = sensor.generic_asset.get_attribute("capacity_in_mw")
if ems_capacity is not None:
ems_constraints["derivative min"] = ems_capacity * -1
ems_constraints["derivative max"] = ems_capacity

ems_schedule, expected_costs, scheduler_results = device_scheduler(
device_constraints,
Expand Down
4 changes: 4 additions & 0 deletions flexmeasures/data/models/planning/exceptions.py
Expand Up @@ -6,6 +6,10 @@ class UnknownMarketException(Exception):
pass


class UnknownForecastException(Exception):
pass


class UnknownPricesException(Exception):
pass

Expand Down
16 changes: 12 additions & 4 deletions flexmeasures/data/models/planning/solver.py
Expand Up @@ -173,15 +173,21 @@ def ems_derivative_min_select(m, j):

def device_derivative_down_efficiency(m, d, j):
try:
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
return device_constraints[d]["derivative down efficiency"].iloc[j]
eff = device_constraints[d]["derivative down efficiency"].iloc[j]
except KeyError:
return 1
if np.isnan(eff):
return 1
return eff

def device_derivative_up_efficiency(m, d, j):
try:
return device_constraints[d]["derivative up efficiency"].iloc[j]
eff = device_constraints[d]["derivative up efficiency"].iloc[j]
except KeyError:
return 1
if np.isnan(eff):
return 1
return eff

model.up_price = Param(model.c, model.j, initialize=price_up_select)
model.down_price = Param(model.c, model.j, initialize=price_down_select)
Expand Down Expand Up @@ -237,17 +243,19 @@ def device_derivative_bounds(m, d, j):
)

def device_down_derivative_bounds(m, d, j):
"""Strictly non-positive."""
return (
m.device_derivative_min[d, j],
min(m.device_derivative_min[d, j], 0),
m.device_power_down[d, j],
0,
)

def device_up_derivative_bounds(m, d, j):
"""Strictly non-negative."""
return (
0,
m.device_power_up[d, j],
m.device_derivative_max[d, j],
max(0, m.device_derivative_max[d, j]),
)

def ems_derivative_bounds(m, j):
Expand Down