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

Document & refactor scheduling specs for storage flexibility model #511

Merged
merged 41 commits into from Nov 18, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ce8169c
Better documentation of flexibility model for storage in endpoint; re…
nhoening Oct 1, 2022
3bed7c0
add changelog entry
nhoening Oct 1, 2022
1fba29e
make tests work, include updating older API versions, make prefer_cha…
nhoening Oct 1, 2022
0e9259d
use storage_specs in CLI command, as well
nhoening Oct 1, 2022
c2b2787
remove default resolution of 15M, for now pass in what you want
nhoening Oct 2, 2022
c0400ae
various review comments
nhoening Oct 28, 2022
4718a8a
black
nhoening Oct 29, 2022
2eef27d
fix tests
nhoening Oct 29, 2022
1f68659
always load sensor when checking storage specs
nhoening Oct 31, 2022
2beb928
begin to handle source model and version during scheduling
nhoening Oct 31, 2022
378caba
we can get multiple sources from our query (in the old setting, when …
nhoening Oct 31, 2022
5b48baf
give our two in-built schedulers an official __author__ and __version__
nhoening Oct 31, 2022
b664910
review comments
nhoening Oct 31, 2022
e9ff60b
refactor getting data source for a job to util function; use the actu…
nhoening Nov 2, 2022
5fe72dc
pass sensor to check_storage_specs, as we always have it already
nhoening Nov 2, 2022
f80171d
wrap Scheduler in classes, unify data source handling a bit more
nhoening Nov 3, 2022
c04f0d7
Merge branch 'main' into refactor-scheduling-storage-specs
nhoening Nov 4, 2022
22cb852
Support pandas 1.4 (#525)
Flix6x Nov 10, 2022
dd47dab
Stop requiring min/max SoC attributes, which have defaults:
Flix6x Nov 10, 2022
add377f
Set up device constraint columns for efficiencies in Charge Point sch…
Flix6x Nov 10, 2022
ccab2ee
Derive flow constraints for battery scheduling, too (copied from Char…
Flix6x Nov 10, 2022
6344cb0
Refactor: rename BatteryScheduler to StorageScheduler
Flix6x Nov 10, 2022
7f9eced
Warn for deprecation of
Flix6x Nov 10, 2022
4bc593c
Use StorageScheduler instead of ChargingStationScheduler
Flix6x Nov 10, 2022
ec40bc0
Deprecate ChargingStationScheduler
Flix6x Nov 10, 2022
6914a4b
Refactor: move StorageScheduler to dedicated module
Flix6x Nov 10, 2022
5e6bd4e
Update docstring
Flix6x Nov 10, 2022
beb2770
fix test
Flix6x Nov 10, 2022
3bf1b97
flake8
Flix6x Nov 10, 2022
ed3284d
Merge remote-tracking branch 'origin/main' into refactor-scheduling-s…
Flix6x Nov 10, 2022
a9899a2
Lose the v in version strings; prefer versions showing up as 'version…
Flix6x Nov 10, 2022
ad22c35
Refactor: rename module
Flix6x Nov 11, 2022
4efe883
Deal with empty SoC targets
Flix6x Nov 11, 2022
09cf700
Stop wrapping DataFrame representations in logging
Flix6x Oct 9, 2022
5a3845d
Log warning instead of raising UnknownForecastException, and assume z…
Flix6x Oct 9, 2022
172753d
mention scheduler merging in changelog
nhoening Nov 16, 2022
78250ef
amend existing data source information to reflect our StorageScheduler
nhoening Nov 16, 2022
ac2ddcc
Merge branch 'main' into refactor-scheduling-storage-specs
nhoening Nov 17, 2022
0bf52dd
add db upgrade notice to changelog
nhoening Nov 17, 2022
e1c2b47
Merge branch 'refactor-scheduling-storage-specs' of github.com:FlexMe…
nhoening Nov 17, 2022
1368fab
more specific downgrade command
nhoening Nov 18, 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
3 changes: 2 additions & 1 deletion documentation/changelog.rst
Expand Up @@ -8,7 +8,7 @@ v0.12.0 | October XX, 2022
New features
-------------

* Hit the replay button to replay what happened, available on the sensor and asset pages [see `PR #463 <http://www.github.com/FlexMeasures/flexmeasures/pull/463>`_]
* Hit the replay button to visually replay what happened, available on the sensor and asset pages [see `PR #463 <http://www.github.com/FlexMeasures/flexmeasures/pull/463>`_]
nhoening marked this conversation as resolved.
Show resolved Hide resolved
* Ability to provide your own custom scheduling function [see `PR #505 <http://www.github.com/FlexMeasures/flexmeasures/pull/505>`_]
* Visually distinguish forecasts/schedules (dashed lines) from measurements (solid lines), and expand the tooltip with timing info regarding the forecast/schedule horizon or measurement lag [see `PR #503 <http://www.github.com/FlexMeasures/flexmeasures/pull/503>`_]
* The asset page also allows to show sensor data from other assets that belong to the same account [see `PR #500 <http://www.github.com/FlexMeasures/flexmeasures/pull/500>`_]
Expand All @@ -23,6 +23,7 @@ Infrastructure / Support

* Reduce size of Docker image (from 2GB to 1.4GB) [see `PR #512 <http://www.github.com/FlexMeasures/flexmeasures/pull/512>`_]
* Remove bokeh dependency and obsolete UI views [see `PR #476 <http://www.github.com/FlexMeasures/flexmeasures/pull/476>`_]
* Improve documentation and code w.r.t. storage flexibility mnodeling [see `PR #511 <http://www.github.com/FlexMeasures/flexmeasures/pull/511>`_]
nhoening marked this conversation as resolved.
Show resolved Hide resolved


v0.11.2 | September 6, 2022
Expand Down
8 changes: 6 additions & 2 deletions documentation/plugin/customisation.rst
Expand Up @@ -33,9 +33,13 @@ The following minimal example gives you an idea of the inputs and outputs:
*args,
**kwargs
):
"""Just a dummy scheduler, advising to do nothing"""
"""
Just a dummy scheduler that always plans to consume at maximum capacity.
(Schedulers return positive values for consumption, and negative values for production)
"""
return pd.Series(
0, index=pd.date_range(start, end, freq=resolution, closed="left")
sensor.get_attribute("capacity_in_mw"),
index=pd.date_range(start, end, freq=resolution, closed="left"),
)


Expand Down
16 changes: 10 additions & 6 deletions flexmeasures/api/v1_2/implementations.py
Expand Up @@ -39,6 +39,7 @@
)
from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.services.resources import has_assets, can_access_asset
from flexmeasures.data.models.planning.utils import ensure_storage_specs
from flexmeasures.utils.time_utils import duration_isoformat


Expand Down Expand Up @@ -93,17 +94,20 @@ def get_device_message_response(generic_asset_name_groups, duration):
start = datetime.fromisoformat(
sensor.generic_asset.get_attribute("soc_datetime")
)
end = start + planning_horizon
resolution = sensor.event_resolution

# Schedule the asset
storage_specs = dict(
soc_at_start=sensor.generic_asset.get_attribute("soc_in_mwh"),
prefer_charging_sooner=False,
)
storage_specs = ensure_storage_specs(
storage_specs, sensor_id, start, end, resolution
)
try:
schedule = schedule_battery(
sensor,
start,
start + planning_horizon,
resolution,
soc_at_start=sensor.generic_asset.get_attribute("soc_in_mwh"),
prefer_charging_sooner=False,
sensor, start, end, resolution, storage_specs=storage_specs
)
except UnknownPricesException:
return unknown_prices()
Expand Down
12 changes: 7 additions & 5 deletions flexmeasures/api/v1_3/implementations.py
Expand Up @@ -364,11 +364,13 @@ def post_udi_event_response(unit: str, prior: datetime):
end_of_schedule,
resolution=resolution,
belief_time=prior, # server time if no prior time was sent
soc_at_start=value,
soc_targets=soc_targets,
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
storage_specs=dict(
soc_at_start=value,
soc_targets=soc_targets,
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
),
job_id=form.get("event"),
enqueue=True,
)
Expand Down
62 changes: 48 additions & 14 deletions flexmeasures/api/v3_0/sensors.py
Expand Up @@ -204,7 +204,7 @@ def get_data(self, response: dict):
validate=validate.Range(min=0, max=1),
data_key="roundtrip-efficiency",
),
"value": fields.Float(data_key="soc-at-start"),
"start_value": fields.Float(data_key="soc-at-start"),
"soc_min": fields.Float(data_key="soc-min"),
"soc_max": fields.Float(data_key="soc-max"),
"start_of_schedule": AwareDateTimeField(
Expand All @@ -220,6 +220,9 @@ 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"),
"prefer_charging_sooner": fields.Bool(
data_key="prefer-charging-sooner", required=False
),
# todo: add a duration parameter, instead of falling back to FLEXMEASURES_PLANNING_HORIZON
"consumption_price_sensor": SensorIdField(
data_key="consumption-price-sensor", required=False
Expand All @@ -241,6 +244,7 @@ def trigger_schedule( # noqa: C901
unit: str,
prior: datetime,
roundtrip_efficiency: Optional[ur.Quantity] = None,
prefer_charging_sooner: Optional[bool] = True,
consumption_price_sensor: Optional[Sensor] = None,
production_price_sensor: Optional[Sensor] = None,
inflexible_device_sensors: Optional[List[Sensor]] = None,
Expand All @@ -251,11 +255,37 @@ def trigger_schedule( # noqa: C901

.. :quickref: Schedule; Trigger scheduling job

The message should contain a flexibility model.
Trigger FlexMeasures to create a schedule for this sensor.
The assumption is that this sensor is the power sensor on a flexible asset.

In this request, you can describe:

- the schedule (start, unit, prior)
- the flexibility model for the sensor (see below, only storage models are supported at the moment)
- the EMS the sensor operates in (inflexible device sensors, and sensors that put a price on consumption and/or production)

Note: This endpoint does not support to schedule an EMS with multiple flexible sensors at once. This will happen in another endpoint.
See https://github.com/FlexMeasures/flexmeasures/issues/485. Until then, it is possible to call this endpoint for one flexible endpoint at a time
(considering already scheduled sensors as inflexible).

Flexibility models apply to the sensor's asset type:

1) For storage sensors (e.g. battery, charge points), the schedule deals with the state of charge (SOC).
The possible flexibility parameters are:

- soc-at-start (defaults to 0)
- soc-unit (kWh or MWh)
- soc-min (defaults to 0)
- soc-max (defaults to max soc target)
- soc-targets (defaults to NaN values)
- roundtrip-efficiency (defaults to 100%)
- prefer-charging-sooner (defaults to True)
nhoening marked this conversation as resolved.
Show resolved Hide resolved

2) Heat pump sensors are work in progress.

**Example request A**

This message triggers a schedule starting at 10.00am, at which the state of charge (soc) is 12.1 kWh.
This message triggers a schedule for a storage asset, starting at 10.00am, at which the state of charge (soc) is 12.1 kWh.

.. code-block:: json

Expand Down Expand Up @@ -324,19 +354,19 @@ def trigger_schedule( # noqa: C901
# todo: if a soc-sensor entity address is passed, persist those values to the corresponding sensor
# (also update the note in posting_data.rst about flexibility states not being persisted).

# get value
if "value" not in kwargs:
# get starting value
if "start_value" not in kwargs:
return ptus_incomplete()
try:
value = float(kwargs.get("value")) # type: ignore
start_value = float(kwargs.get("start_value")) # type: ignore
except ValueError:
extra_info = "Request includes empty or ill-formatted value(s)."
current_app.logger.warning(extra_info)
return ptus_incomplete(extra_info)
if unit == "kWh":
value = value / 1000.0
start_value = start_value / 1000.0

# Convert round-trip efficiency to dimensionless
# Convert round-trip efficiency to dimensionless (to the (0,1] range)
if roundtrip_efficiency is not None:
roundtrip_efficiency = roundtrip_efficiency.to(
ur.Quantity("dimensionless")
Expand All @@ -345,6 +375,7 @@ def trigger_schedule( # noqa: C901
# get optional min and max SOC
soc_min = kwargs.get("soc_min", None)
soc_max = kwargs.get("soc_max", None)
# TODO: review when we moved away from capacity having to be described in MWh
if soc_min is not None and unit == "kWh":
soc_min = soc_min / 1000.0
if soc_max is not None and unit == "kWh":
Expand All @@ -361,7 +392,7 @@ def trigger_schedule( # noqa: C901
start_of_schedule, end_of_schedule, freq=resolution, closed="right"
), # note that target values are indexed by their due date (i.e. closed="right")
)
# todo: move deserialization of targets into TargetSchema
# todo: move this deserialization of targets into newly-created ScheduleTargetSchema
for target in kwargs.get("targets", []):

# get target value
Expand Down Expand Up @@ -411,11 +442,14 @@ def trigger_schedule( # noqa: C901
end_of_schedule,
resolution=resolution,
belief_time=prior, # server time if no prior time was sent
soc_at_start=value,
soc_targets=soc_targets,
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
storage_specs=dict(
soc_at_start=start_value,
soc_targets=soc_targets,
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
prefer_charging_sooner=prefer_charging_sooner,
),
consumption_price_sensor=consumption_price_sensor,
production_price_sensor=production_price_sensor,
inflexible_device_sensors=inflexible_device_sensors,
Expand Down
12 changes: 7 additions & 5 deletions flexmeasures/cli/data_add.py
Expand Up @@ -958,11 +958,13 @@ def create_schedule(
end_of_schedule=end,
belief_time=server_now(),
resolution=power_sensor.event_resolution,
soc_at_start=soc_at_start,
soc_targets=soc_targets,
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
storage_specs=dict(
soc_at_start=soc_at_start,
soc_targets=soc_targets,
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
),
consumption_price_sensor=consumption_price_sensor,
production_price_sensor=production_price_sensor,
)
Expand Down
32 changes: 9 additions & 23 deletions flexmeasures/data/models/planning/battery.py
Expand Up @@ -20,12 +20,7 @@ def schedule_battery(
start: datetime,
end: datetime,
resolution: timedelta,
soc_at_start: float,
soc_targets: Optional[pd.Series] = None,
soc_min: Optional[float] = None,
soc_max: Optional[float] = None,
roundtrip_efficiency: Optional[float] = None,
prefer_charging_sooner: bool = True,
storage_specs: dict,
consumption_price_sensor: Optional[Sensor] = None,
production_price_sensor: Optional[Sensor] = None,
inflexible_device_sensors: Optional[List[Sensor]] = None,
Expand All @@ -37,6 +32,13 @@ def schedule_battery(
For the resulting consumption schedule, consumption is defined as positive values.
"""

soc_at_start = storage_specs.get("soc_at_start")
soc_targets = storage_specs.get("soc_targets")
soc_min = storage_specs.get("soc_min")
soc_max = storage_specs.get("soc_max")
roundtrip_efficiency = storage_specs.get("roundtrip_efficiency")
prefer_charging_sooner = storage_specs.get("prefer_charging_sooner", True)

# Check for required Sensor attributes
sensor.check_required_attributes(
[
Expand All @@ -46,19 +48,6 @@ def schedule_battery(
],
)

# Check for round-trip efficiency
if roundtrip_efficiency is None:
# Get default from sensor, or use 100% otherwise
roundtrip_efficiency = sensor.get_attribute("roundtrip_efficiency", 1)
if roundtrip_efficiency <= 0 or roundtrip_efficiency > 1:
raise ValueError("roundtrip_efficiency expected within the interval (0, 1]")

# Check for min and max SOC, or get default from sensor
if soc_min is None:
soc_min = sensor.get_attribute("min_soc_in_mwh")
if soc_max is None:
soc_max = sensor.get_attribute("max_soc_in_mwh")

# Check for known prices or price forecasts, trimming planning window accordingly
up_deviation_prices, (start, end) = get_prices(
(start, end),
Expand All @@ -79,10 +68,6 @@ def schedule_battery(

start = pd.Timestamp(start).tz_convert("UTC")
end = pd.Timestamp(end).tz_convert("UTC")
if soc_targets is not None:
# 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")
soc_targets = soc_targets[start + resolution : end]

# 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.
Expand Down Expand Up @@ -128,6 +113,7 @@ def schedule_battery(
sensor=inflexible_sensor,
)
if soc_targets is not None:
soc_targets = soc_targets.tz_convert("UTC")
device_constraints[0]["equals"] = soc_targets.shift(
-1, freq=resolution
).values * (timedelta(hours=1) / resolution) - soc_at_start * (
Expand Down
31 changes: 8 additions & 23 deletions flexmeasures/data/models/planning/charging_station.py
Expand Up @@ -20,12 +20,7 @@ def schedule_charging_station(
start: datetime,
end: datetime,
resolution: timedelta,
soc_at_start: float,
soc_targets: pd.Series,
soc_min: Optional[float] = None,
soc_max: Optional[float] = None,
roundtrip_efficiency: Optional[float] = None,
prefer_charging_sooner: bool = True,
storage_specs: dict,
consumption_price_sensor: Optional[Sensor] = None,
production_price_sensor: Optional[Sensor] = None,
inflexible_device_sensors: Optional[List[Sensor]] = None,
Expand All @@ -38,24 +33,16 @@ def schedule_charging_station(
Todo: handle uni-directional charging by setting the "min" or "derivative min" constraint to 0
"""

soc_at_start = storage_specs.get("soc_at_start")
soc_targets = storage_specs.get("soc_targets")
soc_min = storage_specs.get("soc_min")
soc_max = storage_specs.get("soc_max")
roundtrip_efficiency = storage_specs.get("roundtrip_efficiency")
prefer_charging_sooner = storage_specs.get("prefer_charging_sooner", True)

# Check for required Sensor attributes
sensor.check_required_attributes([("capacity_in_mw", (float, int))])

# Check for round-trip efficiency
if roundtrip_efficiency is None:
# Get default from sensor, or use 100% otherwise
roundtrip_efficiency = sensor.get_attribute("roundtrip_efficiency", 1)
if roundtrip_efficiency <= 0 or roundtrip_efficiency > 1:
raise ValueError("roundtrip_efficiency expected within the interval (0, 1]")

# Check for min and max SOC, or get default from sensor
if soc_min is None:
# Can't drain the EV battery by more than it contains
soc_min = sensor.get_attribute("min_soc_in_mwh", 0)
if soc_max is None:
# Lacking information about the battery's nominal capacity, we use the highest target value as the maximum state of charge
soc_max = sensor.get_attribute("max_soc_in_mwh", max(soc_targets.values))

# Check for known prices or price forecasts, trimming planning window accordingly
up_deviation_prices, (start, end) = get_prices(
(start, end),
Expand All @@ -74,11 +61,9 @@ def schedule_charging_station(
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")
end = pd.Timestamp(end).tz_convert("UTC")
soc_targets = soc_targets[start + resolution : end]

# 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.
Expand Down