From 7ff14805de0fc8fa32b1c2fafc4533f03aa4c445 Mon Sep 17 00:00:00 2001 From: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Date: Wed, 24 Aug 2022 17:44:20 +0200 Subject: [PATCH] Issue 450 api option to customize which price and power sensors to take into account for scheduling (#451) Introduce three optional fields to the sensors/(id)/schedules/trigger endpoint to allow further customization of optimization context: - consumption-price-sensor: prices upward deviations from previous zero-consumption commitments - production-price-sensor: prices downward deviations from previous zero-consumption commitments - inflexible-device-sensors: their power forecasts are included in the aggregate load * Add optional API fields to specify which price sensors to use for consumption and feed-in Signed-off-by: F.N. Claessen * Rename API fields Signed-off-by: F.N. Claessen * Add optional API field to specify which inflexible device sensors to include in the aggregate power flow Signed-off-by: F.N. Claessen * Add test data for inflexible device Signed-off-by: F.N. Claessen * Add test case for scheduling a battery including an inflexible device Signed-off-by: F.N. Claessen * Fix boundary conditions for including inflexible devices Signed-off-by: F.N. Claessen * Assume perfect efficiency if no efficiency information is available Signed-off-by: F.N. Claessen * Switch from random periodic data to a step function, leaving a little headroom with respect to the PV system's nominal capacity Signed-off-by: F.N. Claessen * Return test data with conftest setup Signed-off-by: F.N. Claessen * Skip redundant sensor queries in tests Signed-off-by: F.N. Claessen * Refactor: rename variable for clarity Signed-off-by: F.N. Claessen * Take into account the nominal capacity of the asset, if given Signed-off-by: F.N. Claessen * Refactor: rename variable in test Signed-off-by: F.N. Claessen * Convert consumption/production power sign for our scheduler Signed-off-by: F.N. Claessen * Split fixtures, set up the building with a flexible device and two inflexible devices Signed-off-by: F.N. Claessen * Test building solver Signed-off-by: F.N. Claessen * Fix passing along list of inflexible device sensors for battery scheduling Signed-off-by: F.N. Claessen * Restrict data used for scheduling to what was known at the time of scheduling Signed-off-by: F.N. Claessen * Fix tests, given that the values for solar PV and residual demand (defined in the conftest) are measurements rather than forecasts Signed-off-by: F.N. Claessen * Let test schedule the future instead of the past, which requires forecasts to be present Signed-off-by: F.N. Claessen * Set up test data for inflexible devices in the Europe/Amsterdam timezone instead of UTC Signed-off-by: F.N. Claessen * Set up test data for EPEX market prices in the Europe/Amsterdam timezone instead of UTC Signed-off-by: F.N. Claessen * Fix scheduling tests corresponding to having market prices available in the Europe/Amsterdam timezone instead of UTC Signed-off-by: F.N. Claessen * black Signed-off-by: F.N. Claessen * Add comment from earlier commit Signed-off-by: F.N. Claessen * Clear up inline comment Signed-off-by: F.N. Claessen * Rename variable internally Signed-off-by: F.N. Claessen * Rename API field Signed-off-by: F.N. Claessen * Rename CLI field with future deprecation warning Signed-off-by: F.N. Claessen * Clarify that the consumption price is being applied to the aggregate consumption of the devices, instead of being applied to the consumption of the single flexible device under consideration. Likewise for production. Signed-off-by: F.N. Claessen * Clarify description of aggregate power flow Signed-off-by: F.N. Claessen * Add return type annotation Signed-off-by: F.N. Claessen * Expand docstring Signed-off-by: F.N. Claessen * Fix use of wrong exception Signed-off-by: F.N. Claessen * Refactor: rename util function Signed-off-by: F.N. Claessen * Changelog entry Signed-off-by: F.N. Claessen * Implement recommended docstring revision Signed-off-by: F.N. Claessen Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 1 + documentation/index.rst | 2 +- .../tut/toy-example-from-scratch.rst | 4 +- flexmeasures/api/v1_3/tests/utils.py | 6 +- .../api/v2_0/tests/test_api_v2_0_assets.py | 2 +- flexmeasures/api/v3_0/sensors.py | 27 +++- flexmeasures/api/v3_0/tests/utils.py | 6 +- flexmeasures/cli/data_add.py | 35 ++++- flexmeasures/conftest.py | 25 +++- flexmeasures/data/models/planning/battery.py | 53 +++++-- .../data/models/planning/charging_station.py | 52 +++++-- .../data/models/planning/exceptions.py | 4 + flexmeasures/data/models/planning/solver.py | 18 ++- .../data/models/planning/tests/conftest.py | 139 +++++++++++++++++- .../data/models/planning/tests/test_solver.py | 131 +++++++++++++++-- flexmeasures/data/models/planning/utils.py | 58 ++++++-- flexmeasures/data/services/scheduling.py | 24 ++- .../data/tests/test_scheduling_jobs.py | 7 +- .../tests/test_scheduling_jobs_fresh_db.py | 7 +- 19 files changed, 516 insertions(+), 85 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4600ab46d..a399ea198 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -14,6 +14,7 @@ New features * Add CLI command ``flexmeasures jobs show-queues`` [see `PR #455 `_] * Switched from 12-hour AM/PM to 24-hour clock notation for time series chart axis labels [see `PR #446 `_] * Get data in a given resolution [see `PR #458 `_] +* New API options to further customize the optimization context for scheduling, including the ability to use different prices for consumption and production (feed-in) [see `PR #451 `_] Bugfixes ----------- diff --git a/documentation/index.rst b/documentation/index.rst index c073af47f..ed0c6078e 100644 --- a/documentation/index.rst +++ b/documentation/index.rst @@ -41,7 +41,7 @@ A tiny, but complete example: Let's install FlexMeasures from scratch. Then, usi $ flexmeasures db upgrade # create tables $ flexmeasures add toy-account --kind battery # setup account & a user, a battery (Id 2) and a market (Id 3) $ flexmeasures add beliefs --sensor-id 3 --source toy-user prices-tomorrow.csv # load prices, also possible per API - $ flexmeasures add schedule --sensor-id 2 --optimization-context-id 3 \ + $ flexmeasures add schedule --sensor-id 2 --consumption-price-sensor 3 \ --start ${TOMORROW}T07:00+01:00 --duration PT12H \ --soc-at-start 50% --roundtrip-efficiency 90% # this is also possible per API $ flexmeasures show beliefs --sensor-id 2 --start ${TOMORROW}T07:00:00+01:00 --duration PT12H # also visible per UI, of course diff --git a/documentation/tut/toy-example-from-scratch.rst b/documentation/tut/toy-example-from-scratch.rst index 201ce4f7c..bc656d214 100644 --- a/documentation/tut/toy-example-from-scratch.rst +++ b/documentation/tut/toy-example-from-scratch.rst @@ -21,7 +21,7 @@ Below are the ``flexmeasures`` CLI commands we'll run, and which we'll explain s # load prices to optimise the schedule against $ flexmeasures add beliefs --sensor-id 3 --source toy-user prices-tomorrow.csv # make the schedule - $ flexmeasures add schedule --sensor-id 2 --optimization-context-id 3 \ + $ flexmeasures add schedule --sensor-id 2 --consumption-price-sensor 3 \ --start ${TOMORROW}T07:00+01:00 --duration PT12H \ --soc-at-start 50% --roundtrip-efficiency 90% @@ -268,7 +268,7 @@ To keep it short, we'll only ask for a 12-hour window starting at 7am. Finally, .. code-block:: console - $ flexmeasures add schedule --sensor-id 2 --optimization-context-id 3 \ + $ flexmeasures add schedule --sensor-id 2 --consumption-price-sensor 3 \ --start ${TOMORROW}T07:00+01:00 --duration PT12H \ --soc-at-start 50% --roundtrip-efficiency 90% New schedule is stored. diff --git a/flexmeasures/api/v1_3/tests/utils.py b/flexmeasures/api/v1_3/tests/utils.py index 0040eb4ff..2e64dc13f 100644 --- a/flexmeasures/api/v1_3/tests/utils.py +++ b/flexmeasures/api/v1_3/tests/utils.py @@ -29,15 +29,15 @@ def message_for_post_udi_event( message = { "type": "PostUdiEventRequest", "event": "ea1.2018-06.localhost:%s:204:soc", - "datetime": "2015-01-01T00:00:00+00:00", + "datetime": "2015-01-01T00:00:00+01:00", "value": 12.1, "unit": "kWh", } if targets: message["event"] = message["event"] + "-with-targets" - message["targets"] = [{"value": 25, "datetime": "2015-01-02T23:00:00+00:00"}] + message["targets"] = [{"value": 25, "datetime": "2015-01-02T23:00:00+01:00"}] if unknown_prices: message[ "datetime" - ] = "2040-01-01T00:00:00+00:00" # We have no beliefs in our test database about 2040 prices + ] = "2040-01-01T00:00:00+01:00" # We have no beliefs in our test database about 2040 prices return message diff --git a/flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py b/flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py index ab0f4eb05..7d1a84df6 100644 --- a/flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py +++ b/flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py @@ -100,7 +100,7 @@ def test_get_assets(client, add_charging_station_assets, use_owner_id, num_asset battery = asset assert battery assert pd.Timestamp(battery["soc_datetime"]) == pd.Timestamp( - "2015-01-01T00:00:00+00:00" + "2015-01-01T00:00:00+01:00" ) assert battery["owner_id"] == test_prosumer2_id assert battery["capacity_in_mw"] == 2 diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 5b7fb0f1f..0ff900f0a 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/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 @@ -221,6 +221,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 + "consumption_price_sensor": SensorIdField( + data_key="consumption-price-sensor", required=False + ), + "production_price_sensor": SensorIdField( + data_key="production-price-sensor", required=False + ), + "inflexible_device_sensors": fields.List( + SensorIdField, data_key="inflexible-device-sensors", required=False + ), }, location="json", ) @@ -232,6 +241,9 @@ def trigger_schedule( # noqa: C901 unit: str, prior: datetime, roundtrip_efficiency: Optional[ur.Quantity] = None, + consumption_price_sensor: Optional[Sensor] = None, + production_price_sensor: Optional[Sensor] = None, + inflexible_device_sensors: Optional[List[Sensor]] = None, **kwargs, ): """ @@ -259,6 +271,11 @@ 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%. + Aggregate consumption (of all devices within this EMS) should be priced by sensor 9, + and aggregate production should be priced by sensor 10, + where the aggregate power flow in the EMS is described by the sum over sensors 13, 14 and 15 + (plus the flexible sensor being optimized, of course). + Note that, if forecasts for sensors 13, 14 and 15 are not available, a schedule cannot be computed. .. code-block:: json @@ -274,7 +291,10 @@ def trigger_schedule( # noqa: C901 ], "soc-min": 10, "soc-max": 25, - "roundtrip-efficiency": 0.98 + "roundtrip-efficiency": 0.98, + "consumption-price-sensor": 9, + "production-price-sensor": 10, + "inflexible-device-sensors": [13, 14, 15] } **Example response** @@ -396,6 +416,9 @@ def trigger_schedule( # noqa: C901 soc_min=soc_min, soc_max=soc_max, roundtrip_efficiency=roundtrip_efficiency, + consumption_price_sensor=consumption_price_sensor, + production_price_sensor=production_price_sensor, + inflexible_device_sensors=inflexible_device_sensors, enqueue=True, ) diff --git a/flexmeasures/api/v3_0/tests/utils.py b/flexmeasures/api/v3_0/tests/utils.py index 153affda8..c9d3519b0 100644 --- a/flexmeasures/api/v3_0/tests/utils.py +++ b/flexmeasures/api/v3_0/tests/utils.py @@ -39,16 +39,16 @@ def message_for_post_udi_event( targets: bool = False, ) -> dict: message = { - "start": "2015-01-01T00:00:00+00:00", + "start": "2015-01-01T00:00:00+01:00", "soc-at-start": 12.1, "soc-unit": "kWh", } if targets: message["soc-targets"] = [ - {"value": 25, "datetime": "2015-01-02T23:00:00+00:00"} + {"value": 25, "datetime": "2015-01-02T23:00:00+01:00"} ] if unknown_prices: message[ "start" - ] = "2040-01-01T00:00:00+00:00" # We have no beliefs in our test database about 2040 prices + ] = "2040-01-01T00:00:00+01:00" # We have no beliefs in our test database about 2040 prices return message diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index 30bc20341..7cc87e8fa 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -15,6 +15,7 @@ from sqlalchemy.exc import IntegrityError from timely_beliefs.sensors.func_store.knowledge_horizons import x_days_ago_at_y_oclock import timely_beliefs as tb +import timely_beliefs.utils as tb_utils from workalendar.registry import registry as workalendar_registry from flexmeasures.data import db @@ -777,12 +778,26 @@ def create_forecasts( required=True, help="Create schedule for this sensor. Follow up with the sensor's ID.", ) +@click.option( + "--consumption-price-sensor", + "consumption_price_sensor", + type=SensorIdField(), + required=False, + help="Optimize consumption against this sensor. The sensor typically records an electricity price (e.g. in EUR/kWh), but this field can also be used to optimize against some emission intensity factor (e.g. in kg CO₂ eq./kWh). Follow up with the sensor's ID.", +) +@click.option( + "--production-price-sensor", + "production_price_sensor", + type=SensorIdField(), + required=False, + help="Optimize production against this sensor. Defaults to the consumption price sensor. The sensor typically records an electricity price (e.g. in EUR/kWh), but this field can also be used to optimize against some emission intensity factor (e.g. in kg CO₂ eq./kWh). Follow up with the sensor's ID.", +) @click.option( "--optimization-context-id", "optimization_context_sensor", type=SensorIdField(), required=True, - help="Optimize against this sensor, which measures a price factor or CO₂ intensity factor. Follow up with the sensor's ID.", + help="To be deprecated. Use consumption-price-sensor instead.", ) @click.option( "--start", @@ -847,6 +862,8 @@ def create_forecasts( ) def create_schedule( power_sensor: Sensor, + consumption_price_sensor: Sensor, + production_price_sensor: Sensor, optimization_context_sensor: Sensor, start: datetime, duration: timedelta, @@ -865,10 +882,20 @@ def create_schedule( - only supports datetimes on the hour or a multiple of the sensor resolution thereafter """ + # todo: deprecate the 'optimization-context-id' argument in favor of 'consumption-price-sensor' (announced v0.11.0) + tb_utils.replace_deprecated_argument( + "optimization-context-id", + optimization_context_sensor, + "consumption-price-sensor", + consumption_price_sensor, + ) + # Parse input if not power_sensor.measures_power: click.echo(f"Sensor with ID {power_sensor.id} is not a power sensor.") raise click.Abort() + if production_price_sensor is None: + production_price_sensor = consumption_price_sensor end = start + duration for attribute in ("min_soc_in_mwh", "max_soc_in_mwh"): try: @@ -922,7 +949,8 @@ def create_schedule( soc_min=soc_min, soc_max=soc_max, roundtrip_efficiency=roundtrip_efficiency, - price_sensor=optimization_context_sensor, + consumption_price_sensor=consumption_price_sensor, + production_price_sensor=production_price_sensor, ) if job: print(f"New scheduling job {job.id} has been added to the queue.") @@ -938,7 +966,8 @@ def create_schedule( soc_min=soc_min, soc_max=soc_max, roundtrip_efficiency=roundtrip_efficiency, - price_sensor=optimization_context_sensor, + consumption_price_sensor=consumption_price_sensor, + production_price_sensor=production_price_sensor, ) if success: print("New schedule is stored.") diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 8438cb1c6..b8455cb98 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -3,6 +3,7 @@ from random import random from datetime import datetime, timedelta from typing import List, Dict +import pytz from isodate import parse_duration import pandas as pd @@ -494,14 +495,18 @@ def add_market_prices(db: SQLAlchemy, setup_assets, setup_markets, setup_sources # one day of test data (one complete sine curve) time_slots = pd.date_range( - datetime(2015, 1, 1), datetime(2015, 1, 2), freq="1H", closed="left" + datetime(2015, 1, 1), + datetime(2015, 1, 2), + freq="1H", + closed="left", + tz="Europe/Amsterdam", ) values = [ random() * (1 + np.sin(x * 2 * np.pi / 24)) for x in range(len(time_slots)) ] day1_beliefs = [ TimedBelief( - event_start=as_server_time(dt), + event_start=dt, belief_horizon=timedelta(hours=0), event_value=val, source=setup_sources["Seita"], @@ -513,12 +518,16 @@ def add_market_prices(db: SQLAlchemy, setup_assets, setup_markets, setup_sources # another day of test data (8 expensive hours, 8 cheap hours, and again 8 expensive hours) time_slots = pd.date_range( - datetime(2015, 1, 2), datetime(2015, 1, 3), freq="1H", closed="left" + datetime(2015, 1, 2), + datetime(2015, 1, 3), + freq="1H", + closed="left", + tz="Europe/Amsterdam", ) values = [100] * 8 + [90] * 8 + [100] * 8 day2_beliefs = [ TimedBelief( - event_start=as_server_time(dt), + event_start=dt, belief_horizon=timedelta(hours=0), event_value=val, source=setup_sources["Seita"], @@ -573,7 +582,7 @@ def create_test_battery_assets( max_soc_in_mwh=5, min_soc_in_mwh=0, soc_in_mwh=2.5, - soc_datetime=as_server_time(datetime(2015, 1, 1)), + soc_datetime=pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 1)), soc_udi_event_id=203, latitude=10, longitude=100, @@ -591,7 +600,7 @@ def create_test_battery_assets( max_soc_in_mwh=5, min_soc_in_mwh=0, soc_in_mwh=2.5, - soc_datetime=as_server_time(datetime(2040, 1, 1)), + soc_datetime=pytz.timezone("Europe/Amsterdam").localize(datetime(2040, 1, 1)), soc_udi_event_id=203, latitude=10, longitude=100, @@ -644,7 +653,7 @@ def add_charging_station_assets( max_soc_in_mwh=5, min_soc_in_mwh=0, soc_in_mwh=2.5, - soc_datetime=as_server_time(datetime(2015, 1, 1)), + soc_datetime=pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 1)), soc_udi_event_id=203, latitude=10, longitude=100, @@ -662,7 +671,7 @@ def add_charging_station_assets( max_soc_in_mwh=5, min_soc_in_mwh=0, soc_in_mwh=2.5, - soc_datetime=as_server_time(datetime(2015, 1, 1)), + soc_datetime=pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 1)), soc_udi_event_id=203, latitude=10, longitude=100, diff --git a/flexmeasures/data/models/planning/battery.py b/flexmeasures/data/models/planning/battery.py index 68f799134..d6e3380e0 100644 --- a/flexmeasures/data/models/planning/battery.py +++ b/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 @@ -10,6 +10,7 @@ initialize_series, add_tiny_price_slope, get_prices, + get_power_values, fallback_charging_policy, ) @@ -25,7 +26,10 @@ def schedule_battery( soc_max: Optional[float] = None, roundtrip_efficiency: Optional[float] = None, prefer_charging_sooner: bool = True, - price_sensor: Optional[Sensor] = None, + consumption_price_sensor: Optional[Sensor] = None, + production_price_sensor: Optional[Sensor] = None, + inflexible_device_sensors: Optional[List[Sensor]] = None, + belief_time: Optional[datetime] = 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 @@ -56,13 +60,23 @@ 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, + beliefs_before=belief_time, + price_sensor=consumption_price_sensor, sensor=sensor, allow_trimmed_query_window=True, ) + down_deviation_prices, (start, end) = get_prices( + (start, end), + resolution, + beliefs_before=belief_time, + price_sensor=production_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: @@ -73,20 +87,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 scheduled flexible device for this EMS (at index 0), plus the forecasted inflexible devices (at indices 1 to n). columns = [ "equals", "max", @@ -97,7 +114,19 @@ 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"] = get_power_values( + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + sensor=inflexible_sensor, + ) if soc_targets is not None: device_constraints[0]["equals"] = soc_targets.shift( -1, freq=resolution @@ -121,9 +150,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, diff --git a/flexmeasures/data/models/planning/charging_station.py b/flexmeasures/data/models/planning/charging_station.py index 6fe014178..40a321c63 100644 --- a/flexmeasures/data/models/planning/charging_station.py +++ b/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 @@ -10,6 +10,7 @@ initialize_series, add_tiny_price_slope, get_prices, + get_power_values, fallback_charging_policy, ) @@ -25,7 +26,10 @@ def schedule_charging_station( soc_max: Optional[float] = None, roundtrip_efficiency: Optional[float] = None, prefer_charging_sooner: bool = True, - price_sensor: Optional[Sensor] = None, + consumption_price_sensor: Optional[Sensor] = None, + production_price_sensor: Optional[Sensor] = None, + inflexible_device_sensors: Optional[List[Sensor]] = None, + belief_time: Optional[datetime] = 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 @@ -53,13 +57,23 @@ 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, + beliefs_before=belief_time, + price_sensor=consumption_price_sensor, sensor=sensor, allow_trimmed_query_window=True, ) + down_deviation_prices, (start, end) = get_prices( + (start, end), + resolution, + beliefs_before=belief_time, + price_sensor=production_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") @@ -69,20 +83,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 scheduled flexible device for this EMS (at index 0), plus the forecasted inflexible devices (at indices 1 to n). columns = [ "equals", "max", @@ -91,7 +108,18 @@ 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"] = get_power_values( + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + sensor=inflexible_sensor, + ) device_constraints[0]["equals"] = soc_targets.shift(-1, freq=resolution).values * ( timedelta(hours=1) / resolution ) - soc_at_start * ( @@ -121,9 +149,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, diff --git a/flexmeasures/data/models/planning/exceptions.py b/flexmeasures/data/models/planning/exceptions.py index 50b404a20..2ab594c48 100644 --- a/flexmeasures/data/models/planning/exceptions.py +++ b/flexmeasures/data/models/planning/exceptions.py @@ -6,6 +6,10 @@ class UnknownMarketException(Exception): pass +class UnknownForecastException(Exception): + pass + + class UnknownPricesException(Exception): pass diff --git a/flexmeasures/data/models/planning/solver.py b/flexmeasures/data/models/planning/solver.py index a473553f3..e16af9488 100644 --- a/flexmeasures/data/models/planning/solver.py +++ b/flexmeasures/data/models/planning/solver.py @@ -172,16 +172,24 @@ def ems_derivative_min_select(m, j): return v def device_derivative_down_efficiency(m, d, j): + """Assume perfect efficiency if no efficiency information is available.""" try: - 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): + """Assume perfect efficiency if no efficiency information is available.""" 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) @@ -237,17 +245,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): diff --git a/flexmeasures/data/models/planning/tests/conftest.py b/flexmeasures/data/models/planning/tests/conftest.py index 359f4d79b..89d39ccaf 100644 --- a/flexmeasures/data/models/planning/tests/conftest.py +++ b/flexmeasures/data/models/planning/tests/conftest.py @@ -1,9 +1,146 @@ +from __future__ import annotations + +from datetime import datetime, timedelta import pytest +import pandas as pd + +from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType +from flexmeasures.data.models.time_series import Sensor, TimedBelief + -@pytest.fixture(scope="function", autouse=True) +@pytest.fixture(scope="module", autouse=True) def setup_planning_test_data(db, add_market_prices, add_charging_station_assets): """ Set up data for all planning tests. """ print("Setting up data for planning tests on %s" % db.engine) + + +@pytest.fixture(scope="module") +def building(db, setup_accounts, setup_markets) -> GenericAsset: + """ + Set up a building. + """ + building_type = GenericAssetType(name="building") + db.session.add(building_type) + building = GenericAsset( + name="building", + generic_asset_type=building_type, + owner=setup_accounts["Prosumer"], + attributes=dict( + market_id=setup_markets["epex_da"].id, + capacity_in_mw=2, + ), + ) + db.session.add(building) + return building + + +@pytest.fixture(scope="module") +def flexible_devices(db, building) -> dict[str, Sensor]: + """ + Set up power sensors for flexible devices: + - A battery + - A Charge Point (todo) + """ + battery_sensor = Sensor( + name="battery power sensor", + generic_asset=building, + event_resolution=timedelta(minutes=15), + attributes=dict( + capacity_in_mw=2, + max_soc_in_mwh=5, + min_soc_in_mwh=0, + ), + unit="MW", + ) + db.session.add(battery_sensor) + return { + battery_sensor.name: battery_sensor, + } + + +@pytest.fixture(scope="module") +def inflexible_devices(db, building) -> dict[str, Sensor]: + """ + Set up power sensors for inflexible devices: + - A PV panel + - Residual building demand + """ + pv_sensor = Sensor( + name="PV power sensor", + generic_asset=building, + event_resolution=timedelta(hours=1), + unit="kW", + attributes={"capacity_in_mw": 2}, + ) + db.session.add(pv_sensor) + residual_demand_sensor = Sensor( + name="residual demand power sensor", + generic_asset=building, + event_resolution=timedelta(hours=1), + unit="kW", + attributes={"capacity_in_mw": 2}, + ) + db.session.add(residual_demand_sensor) + return { + pv_sensor.name: pv_sensor, + residual_demand_sensor.name: residual_demand_sensor, + } + + +@pytest.fixture(scope="module") +def add_inflexible_device_forecasts( + db, inflexible_devices, setup_sources +) -> dict[Sensor, list[int | float]]: + """ + Set up inflexible devices and forecasts. + """ + # 2 days of test data + time_slots = pd.date_range( + datetime(2015, 1, 1), + datetime(2015, 1, 3), + freq="15T", + closed="left", + tz="Europe/Amsterdam", + ) + + # PV (8 hours at zero capacity, 8 hours at 90% capacity, and again 8 hours at zero capacity) + headroom = 0.1 # 90% of nominal capacity + pv_sensor = inflexible_devices["PV power sensor"] + capacity = pv_sensor.get_attribute("capacity_in_mw") + pv_values = ( + [0] * (8 * 4) + [(1 - headroom) * capacity] * (8 * 4) + [0] * (8 * 4) + ) * (len(time_slots) // (24 * 4)) + add_as_beliefs(db, pv_sensor, pv_values, time_slots, setup_sources["Seita"]) + + # Residual demand (1 MW continuously) + residual_demand_sensor = inflexible_devices["residual demand power sensor"] + residual_demand_values = [-1] * len(time_slots) + add_as_beliefs( + db, + residual_demand_sensor, + residual_demand_values, + time_slots, + setup_sources["Seita"], + ) + + return { + pv_sensor: pv_values, + residual_demand_sensor: residual_demand_values, + } + + +def add_as_beliefs(db, sensor, values, time_slots, source): + beliefs = [ + TimedBelief( + event_start=dt, + belief_time=time_slots[0], + event_value=val, + source=source, + sensor=sensor, + ) + for dt, val in zip(time_slots, values) + ] + db.session.add_all(beliefs) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 152fdc967..15396e66b 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta import pytest +import pytz import numpy as np import pandas as pd @@ -8,21 +9,33 @@ from flexmeasures.data.models.planning.battery import schedule_battery from flexmeasures.data.models.planning.charging_station import schedule_charging_station from flexmeasures.utils.calculations import integrate_time_series -from flexmeasures.utils.time_utils import as_server_time TOLERANCE = 0.00001 -def test_battery_solver_day_1(add_battery_assets): +@pytest.mark.parametrize("use_inflexible_device", [False, True]) +def test_battery_solver_day_1( + add_battery_assets, add_inflexible_device_forecasts, use_inflexible_device +): epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() - assert Sensor.query.get(battery.get_attribute("market_id")) == epex_da - start = as_server_time(datetime(2015, 1, 1)) - end = as_server_time(datetime(2015, 1, 2)) + assert battery.get_attribute("market_id") == epex_da.id + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 1)) + end = tz.localize(datetime(2015, 1, 2)) resolution = timedelta(minutes=15) soc_at_start = battery.get_attribute("soc_in_mwh") - schedule = schedule_battery(battery, start, end, resolution, soc_at_start) + schedule = schedule_battery( + battery, + start, + end, + resolution, + soc_at_start, + inflexible_device_sensors=add_inflexible_device_forecasts.keys() + if use_inflexible_device + else None, + ) soc_schedule = integrate_time_series(schedule, soc_at_start, decimal_precision=6) with pd.option_context("display.max_rows", None, "display.max_columns", 3): @@ -59,9 +72,10 @@ def test_battery_solver_day_2(add_battery_assets, roundtrip_efficiency: float): """ epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() - assert Sensor.query.get(battery.get_attribute("market_id")) == epex_da - start = as_server_time(datetime(2015, 1, 2)) - end = as_server_time(datetime(2015, 1, 3)) + assert battery.get_attribute("market_id") == epex_da.id + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 2)) + end = tz.localize(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) soc_at_start = battery.get_attribute("soc_in_mwh") soc_min = 0.5 @@ -133,9 +147,10 @@ def test_charging_station_solver_day_2(target_soc, charging_station_name): Sensor.name == charging_station_name ).one_or_none() assert charging_station.get_attribute("capacity_in_mw") == 2 - assert Sensor.query.get(charging_station.get_attribute("market_id")) == epex_da - start = as_server_time(datetime(2015, 1, 2)) - end = as_server_time(datetime(2015, 1, 3)) + assert charging_station.get_attribute("market_id") == epex_da.id + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 2)) + end = tz.localize(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) target_soc_datetime = start + duration_until_target soc_targets = pd.Series( @@ -189,9 +204,10 @@ def test_fallback_to_unsolvable_problem(target_soc, charging_station_name): Sensor.name == charging_station_name ).one_or_none() assert charging_station.get_attribute("capacity_in_mw") == 2 - assert Sensor.query.get(charging_station.get_attribute("market_id")) == epex_da - start = as_server_time(datetime(2015, 1, 2)) - end = as_server_time(datetime(2015, 1, 3)) + assert charging_station.get_attribute("market_id") == epex_da.id + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 2)) + end = tz.localize(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) target_soc_datetime = start + duration_until_target soc_targets = pd.Series( @@ -219,3 +235,88 @@ def test_fallback_to_unsolvable_problem(target_soc, charging_station_name): abs(abs(soc_schedule.loc[target_soc_datetime] - target_soc) - expected_gap) < TOLERANCE ) + + +def test_building_solver_day_2( + db, + add_battery_assets, + add_market_prices, + add_inflexible_device_forecasts, + inflexible_devices, + flexible_devices, +): + """Check battery scheduling results within the context of a building with PV, for day 2, + which is set up with 8 expensive, then 8 cheap, then again 8 expensive hours. + We expect the scheduler to: + - completely discharge within the first 8 hours + - completely charge within the next 8 hours + - completely discharge within the last 8 hours + """ + epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() + battery = flexible_devices["battery power sensor"] + building = battery.generic_asset + assert battery.get_attribute("market_id") == epex_da.id + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 2)) + end = tz.localize(datetime(2015, 1, 3)) + resolution = timedelta(minutes=15) + soc_at_start = 2.5 + soc_min = 0.5 + soc_max = 4.5 + schedule = schedule_battery( + battery, + start, + end, + resolution, + soc_at_start, + soc_min=soc_min, + soc_max=soc_max, + inflexible_device_sensors=inflexible_devices.values(), + ) + soc_schedule = integrate_time_series(schedule, soc_at_start, decimal_precision=6) + + with pd.option_context("display.max_rows", None, "display.max_columns", 3): + print(soc_schedule) + + # Check if constraints were met + capacity = pd.DataFrame( + data=np.sum(np.array(list(add_inflexible_device_forecasts.values())), axis=0), + columns=["inflexible"], + ).tail( + -4 * 24 + ) # remove first 96 quarterhours (the schedule is about the 2nd day) + capacity["max"] = building.get_attribute("capacity_in_mw") + capacity["min"] = -building.get_attribute("capacity_in_mw") + capacity["production headroom"] = capacity["max"] - capacity["inflexible"] + capacity["consumption headroom"] = capacity["inflexible"] - capacity["min"] + capacity["battery production headroom"] = capacity["production headroom"].clip( + upper=battery.get_attribute("capacity_in_mw") + ) + capacity["battery consumption headroom"] = capacity["consumption headroom"].clip( + upper=battery.get_attribute("capacity_in_mw") + ) + capacity[ + "schedule" + ] = schedule.values # consumption is positive, production is negative + with pd.option_context( + "display.max_rows", None, "display.max_columns", None, "display.width", 2000 + ): + print(capacity) + assert (capacity["schedule"] >= -capacity["battery production headroom"]).all() + assert (capacity["schedule"] <= capacity["battery consumption headroom"]).all() + + for soc in soc_schedule.values: + assert soc >= max(soc_min, battery.get_attribute("min_soc_in_mwh")) + assert soc <= battery.get_attribute("max_soc_in_mwh") + + # Check whether the resulting soc schedule follows our expectations for 8 expensive, 8 cheap and 8 expensive hours + assert soc_schedule.iloc[-1] == max( + soc_min, battery.get_attribute("min_soc_in_mwh") + ) # Battery sold out at the end of its planning horizon + + assert soc_schedule.loc[start + timedelta(hours=8)] == max( + soc_min, battery.get_attribute("min_soc_in_mwh") + ) # Sell what you begin with + assert soc_schedule.loc[start + timedelta(hours=16)] == min( + soc_max, battery.get_attribute("max_soc_in_mwh") + ) # Buy what you can to sell later diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index a155003ef..23a9c347e 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -9,6 +9,7 @@ from flexmeasures.data.models.time_series import Sensor, TimedBelief from flexmeasures.data.models.planning.exceptions import ( + UnknownForecastException, UnknownMarketException, UnknownPricesException, ) @@ -61,23 +62,21 @@ def add_tiny_price_slope( def get_market(sensor: Sensor) -> Sensor: """Get market sensor from the sensor's attributes.""" - sensor = Sensor.query.get(sensor.get_attribute("market_id")) - if sensor is None: + price_sensor = Sensor.query.get(sensor.get_attribute("market_id")) + if price_sensor is None: raise UnknownMarketException - return sensor + return price_sensor def get_prices( query_window: Tuple[datetime, datetime], resolution: timedelta, + beliefs_before: Optional[datetime], price_sensor: Optional[Sensor] = None, sensor: Optional[Sensor] = None, allow_trimmed_query_window: bool = True, ) -> Tuple[pd.DataFrame, Tuple[datetime, datetime]]: - """Check for known prices or price forecasts, trimming query window accordingly if allowed. - todo: set a horizon to avoid collecting prices that are not known at the time of constructing the schedule - (this may require implementing a belief time for scheduling jobs). - """ + """Check for known prices or price forecasts, trimming query window accordingly if allowed.""" # Look for the applicable price sensor if price_sensor is None: @@ -92,13 +91,16 @@ def get_prices( event_starts_after=query_window[0], event_ends_before=query_window[1], resolution=to_offset(resolution).freqstr, + beliefs_before=beliefs_before, most_recent_beliefs_only=True, one_deterministic_belief_per_event=True, ) price_df = simplify_index(price_bdf) nan_prices = price_df.isnull().values if nan_prices.all() or price_df.empty: - raise UnknownPricesException("Prices unknown for planning window.") + raise UnknownPricesException( + f"Prices unknown for planning window. (sensor {price_sensor.id})" + ) elif ( nan_prices.any() or pd.Timestamp(price_df.index[0]).tz_convert("UTC") @@ -110,16 +112,52 @@ def get_prices( first_event_start = price_df.first_valid_index() last_event_end = price_df.last_valid_index() + resolution current_app.logger.warning( - f"Prices partially unknown for planning window. Trimming planning window (from {query_window[0]} until {query_window[-1]}) to {first_event_start} until {last_event_end}." + f"Prices partially unknown for planning window (sensor {price_sensor.id}). Trimming planning window (from {query_window[0]} until {query_window[-1]}) to {first_event_start} until {last_event_end}." ) query_window = (first_event_start, last_event_end) else: raise UnknownPricesException( - "Prices partially unknown for planning window." + f"Prices partially unknown for planning window (sensor {price_sensor.id})." ) return price_df, query_window +def get_power_values( + query_window: Tuple[datetime, datetime], + resolution: timedelta, + beliefs_before: Optional[datetime], + sensor: Sensor, +) -> np.ndarray: + """Get measurements or forecasts of an inflexible device represented by a power sensor. + + If the requested schedule lies in the future, the returned data will consist of (the most recent) forecasts (if any exist). + If the requested schedule lies in the past, the returned data will consist of (the most recent) measurements (if any exist). + The latter amounts to answering "What if we could have scheduled under perfect foresight?". + + :param query_window: datetime window within which events occur (equal to the scheduling window) + :param resolution: timedelta used to resample the forecasts to the resolution of the schedule + :param beliefs_before: datetime used to indicate we are interested in the state of knowledge at that time + :param sensor: power sensor representing an energy flow out of the device + :returns: power measurements or forecasts (consumption is positive, production is negative) + """ + bdf: tb.BeliefsDataFrame = TimedBelief.search( + sensor, + event_starts_after=query_window[0], + event_ends_before=query_window[1], + resolution=to_offset(resolution).freqstr, + beliefs_before=beliefs_before, + most_recent_beliefs_only=True, + one_deterministic_belief_per_event=True, + ) # consumption is negative, production is positive + df = simplify_index(bdf) + nan_values = df.isnull().values + if nan_values.any() or df.empty: + raise UnknownForecastException( + f"Forecasts unknown for planning window. (sensor {sensor.id})" + ) + return -df.values + + def fallback_charging_policy( sensor: Sensor, device_constraints: pd.DataFrame, diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 14912dc9b..24915ffaf 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Optional +from typing import List, Optional from flask import current_app import click @@ -38,7 +38,9 @@ def create_scheduling_job( soc_min: Optional[float] = None, soc_max: Optional[float] = None, roundtrip_efficiency: Optional[float] = None, - price_sensor: Optional[Sensor] = None, + consumption_price_sensor: Optional[Sensor] = None, + production_price_sensor: Optional[Sensor] = None, + inflexible_device_sensors: Optional[List[Sensor]] = None, job_id: Optional[str] = None, enqueue: bool = True, ) -> Job: @@ -67,7 +69,9 @@ def create_scheduling_job( soc_min=soc_min, soc_max=soc_max, roundtrip_efficiency=roundtrip_efficiency, - price_sensor=price_sensor, + consumption_price_sensor=consumption_price_sensor, + production_price_sensor=production_price_sensor, + inflexible_device_sensors=inflexible_device_sensors, ), id=job_id, connection=current_app.queues["scheduling"].connection, @@ -98,7 +102,9 @@ def make_schedule( soc_min: Optional[float] = None, soc_max: Optional[float] = None, roundtrip_efficiency: Optional[float] = None, - price_sensor: Optional[Sensor] = None, + consumption_price_sensor: Optional[Sensor] = None, + production_price_sensor: Optional[Sensor] = None, + inflexible_device_sensors: Optional[List[Sensor]] = None, ) -> bool: """Preferably, a starting soc is given. Otherwise, we try to retrieve the current state of charge from the asset (if that is the valid one at the start). @@ -144,7 +150,10 @@ def make_schedule( soc_min, soc_max, roundtrip_efficiency, - price_sensor=price_sensor, + consumption_price_sensor=consumption_price_sensor, + production_price_sensor=production_price_sensor, + inflexible_device_sensors=inflexible_device_sensors, + belief_time=belief_time, ) elif sensor.generic_asset.generic_asset_type.name in ( "one-way_evse", @@ -160,7 +169,10 @@ def make_schedule( soc_min, soc_max, roundtrip_efficiency, - price_sensor=price_sensor, + consumption_price_sensor=consumption_price_sensor, + production_price_sensor=production_price_sensor, + inflexible_device_sensors=inflexible_device_sensors, + belief_time=belief_time, ) else: raise ValueError( diff --git a/flexmeasures/data/tests/test_scheduling_jobs.py b/flexmeasures/data/tests/test_scheduling_jobs.py index 55add185b..1e100cd40 100644 --- a/flexmeasures/data/tests/test_scheduling_jobs.py +++ b/flexmeasures/data/tests/test_scheduling_jobs.py @@ -1,11 +1,11 @@ # flake8: noqa: E402 from datetime import datetime, timedelta +import pytz from flexmeasures.data.models.data_sources import DataSource from flexmeasures.data.models.time_series import Sensor, TimedBelief from flexmeasures.data.tests.utils import work_on_rq, exception_reporter from flexmeasures.data.services.scheduling import create_scheduling_job -from flexmeasures.utils.time_utils import as_server_time def test_scheduling_a_battery(db, app, add_battery_assets, setup_test_data): @@ -15,8 +15,9 @@ def test_scheduling_a_battery(db, app, add_battery_assets, setup_test_data): """ battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() - start = as_server_time(datetime(2015, 1, 2)) - end = as_server_time(datetime(2015, 1, 3)) + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 2)) + end = tz.localize(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) assert ( diff --git a/flexmeasures/data/tests/test_scheduling_jobs_fresh_db.py b/flexmeasures/data/tests/test_scheduling_jobs_fresh_db.py index 9b9efbddd..f1f4bd5ea 100644 --- a/flexmeasures/data/tests/test_scheduling_jobs_fresh_db.py +++ b/flexmeasures/data/tests/test_scheduling_jobs_fresh_db.py @@ -1,4 +1,5 @@ from datetime import timedelta, datetime +import pytz import numpy as np import pandas as pd @@ -7,7 +8,6 @@ from flexmeasures.data.models.time_series import Sensor, TimedBelief from flexmeasures.data.services.scheduling import create_scheduling_job from flexmeasures.data.tests.utils import work_on_rq, exception_reporter -from flexmeasures.utils.time_utils import as_server_time def test_scheduling_a_charging_station( @@ -26,8 +26,9 @@ def test_scheduling_a_charging_station( charging_station = Sensor.query.filter( Sensor.name == "Test charging station" ).one_or_none() - start = as_server_time(datetime(2015, 1, 2)) - end = as_server_time(datetime(2015, 1, 3)) + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 2)) + end = tz.localize(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) target_soc_datetime = start + duration_until_target soc_targets = pd.Series(