diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index e66467bad..4fd40e3fd 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -947,6 +947,20 @@ def create_schedule(ctx): 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( + "--consumption-price-sensor-per-device", + "consumption_price_sensor_per_device", + type=str, + required=False, + help="Optimize consumption against this dictionary of sensors. The sensors typically record electricity prices (e.g. in EUR/kWh), but this field can also be used to optimize against some emission intensity factors (e.g. in kg CO₂ eq./kWh).", +) +@click.option( + "--production-price-sensor-per-device", + "production_price_sensor_per_device", + type=str, + required=False, + help="Optimize production against this dictionary of sensors. Defaults to the consumption price sensor. The sensors typically record electricity prices (e.g. in EUR/kWh), but this field can also be used to optimize against some emission intensity factors (e.g. in kg CO₂ eq./kWh).", +) @click.option( "--optimization-context-id", "optimization_context_sensor", @@ -1029,6 +1043,8 @@ def add_schedule_for_storage( production_price_sensor: Sensor, optimization_context_sensor: Sensor, inflexible_device_sensors: list[Sensor], + consumption_price_sensor_per_device: str, + production_price_sensor_per_device: str, start: datetime, duration: timedelta, soc_at_start: ur.Quantity, @@ -1045,13 +1061,54 @@ def add_schedule_for_storage( - Limited to power sensors (probably possible to generalize to non-electric assets) - Only supports datetimes on the hour or a multiple of the sensor resolution thereafter """ + # Convert the 'consumption_price_sensor_per_device' & 'production_price_sensor_per_device' into dictionary + try: + if consumption_price_sensor_per_device is not None: + consumption_price_sensor_per_device = json.loads( + consumption_price_sensor_per_device + ) + converted_dict = {} + for sensor_id, value in consumption_price_sensor_per_device.items(): + try: + sensor_id = int(sensor_id) + value = int(value) + sensor_obj = SensorIdField() + value = sensor_obj._deserialize(value=value, attr=None, obj=None) + sensor_id = sensor_obj._deserialize( + value=sensor_id, attr=None, obj=None + ) + converted_dict[sensor_id] = value + except ValueError: + click.secho(f"Invalid sensor ID: {sensor_id}", fg="red") + consumption_price_sensor_per_device = converted_dict + if production_price_sensor_per_device is not None: + production_price_sensor_per_device = json.loads( + production_price_sensor_per_device + ) + converted_dict = {} + for sensor_id, value in production_price_sensor_per_device.items(): + try: + sensor_id = int(sensor_id) + value = int(value) + sensor_obj = SensorIdField() + value = sensor_obj._deserialize(value=value, attr=None, obj=None) + sensor_id = sensor_obj._deserialize( + value=sensor_id, attr=None, obj=None + ) + converted_dict[sensor_id] = value + except ValueError: + click.secho(f"Invalid sensor ID: {sensor_id}", fg="red") + production_price_sensor_per_device = converted_dict + except json.JSONDecodeError: + click.secho("Invalid dictionary format.", fg="red") + # 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, - ) + # tb_utils.replace_deprecated_argument( + # "optimization-context-id", + # optimization_context_sensor, + # # "consumption-price-sensor", + # # consumption_price_sensor, + # ) # Parse input and required sensor attributes if not power_sensor.measures_power: @@ -1106,11 +1163,29 @@ def add_schedule_for_storage( "roundtrip-efficiency": roundtrip_efficiency, }, flex_context={ - "consumption-price-sensor": consumption_price_sensor.id, - "production-price-sensor": production_price_sensor.id, "inflexible-device-sensors": [s.id for s in inflexible_device_sensors], }, ) + + if consumption_price_sensor is not None: + scheduling_kwargs["flex_context"][ + "consumption-price-sensor" + ] = consumption_price_sensor.id + if production_price_sensor is not None: + scheduling_kwargs["flex_context"][ + "production-price-sensor" + ] = production_price_sensor.id + if production_price_sensor_per_device is not None: + scheduling_kwargs["flex_context"]["production-price-sensor-per-device"] = { + power.id: price.id + for power, price in production_price_sensor_per_device.items() + } + if consumption_price_sensor_per_device is not None: + scheduling_kwargs["flex_context"]["consumption-price-sensor-per-device"] = { + power.id: price.id + for power, price in consumption_price_sensor_per_device.items() + } + if as_job: job = create_scheduling_job(sensor=power_sensor, **scheduling_kwargs) if job: diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 32d8b7456..68fe1d2c8 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -3,6 +3,7 @@ from flask import current_app import pandas as pd import numpy as np +from typing import Dict from pandas.tseries.frequencies import to_offset from pyomo.core import ( ConcreteModel, @@ -29,8 +30,12 @@ def device_scheduler( # noqa C901 device_constraints: List[pd.DataFrame], ems_constraints: pd.DataFrame, commitment_quantities: List[pd.Series], - commitment_downwards_deviation_price: Union[List[pd.Series], List[float]], - commitment_upwards_deviation_price: Union[List[pd.Series], List[float]], + consumption_price_sensor_per_device: Dict[int, int], + production_price_sensor_per_device: Dict[int, int], + commitment_downwards_deviation_price_array: List[ + Union[List[pd.Series], List[float]] + ], + commitment_upwards_deviation_price_array: List[Union[List[pd.Series], List[float]]], ) -> Tuple[List[pd.Series], float, SolverResults]: """This generic device scheduler is able to handle an EMS with multiple devices, with various types of constraints on the EMS level and on the device level, @@ -54,10 +59,10 @@ def device_scheduler( # noqa C901 Commitments are on an EMS level. Parameter explanations: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) - e.g. in MW or boxes/h - commitment_downwards_deviation_price: penalty for downwards deviations of the flow + commitment_downwards_deviation_price_array: penalty for downwards deviations of the flows - e.g. in EUR/MW or EUR/(boxes/h) - either a single value (same value for each flow value) or a Series (different value for each flow value) - commitment_upwards_deviation_price: penalty for upwards deviations of the flow + commitment_upwards_deviation_price_array: penalty for upwards deviations of the flows All Series and DataFrames should have the same resolution. @@ -89,22 +94,26 @@ def device_scheduler( # noqa C901 ) # Turn prices per commitment into prices per commitment flow - if len(commitment_downwards_deviation_price) != 0: - if all( - isinstance(price, float) for price in commitment_downwards_deviation_price - ): - commitment_downwards_deviation_price = [ - initialize_series(price, start, end, resolution) - for price in commitment_downwards_deviation_price - ] - if len(commitment_upwards_deviation_price) != 0: - if all( - isinstance(price, float) for price in commitment_upwards_deviation_price - ): - commitment_upwards_deviation_price = [ - initialize_series(price, start, end, resolution) - for price in commitment_upwards_deviation_price - ] + for i in range(0, len(commitment_downwards_deviation_price_array)): + if len(commitment_downwards_deviation_price_array[i]) != 0: + if all( + isinstance(price, float) + for price in commitment_downwards_deviation_price_array[i] + ): + commitment_downwards_deviation_price_array[i] = [ + initialize_series(price, start, end, resolution) + for price in commitment_downwards_deviation_price_array[i] + ] + for i in range(0, len(commitment_upwards_deviation_price_array)): + if len(commitment_upwards_deviation_price_array[i]) != 0: + if all( + isinstance(price, float) + for price in commitment_upwards_deviation_price_array[i] + ): + commitment_upwards_deviation_price_array[i] = [ + initialize_series(price, start, end, resolution) + for price in commitment_upwards_deviation_price_array[i] + ] model = ConcreteModel() @@ -114,13 +123,14 @@ def device_scheduler( # noqa C901 0, len(device_constraints[0].index.to_pydatetime()) - 1, doc="Set of datetimes" ) model.c = RangeSet(0, len(commitment_quantities) - 1, doc="Set of commitments") - + # Add parameters - def price_down_select(m, c, j): - return commitment_downwards_deviation_price[c].iloc[j] - def price_up_select(m, c, j): - return commitment_upwards_deviation_price[c].iloc[j] + def price_down_select(m, d, c, j): + return commitment_downwards_deviation_price_array[d][c].iloc[j] + + def price_up_select(m, d, c, j): + return commitment_upwards_deviation_price_array[d][c].iloc[j] def commitment_quantity_select(m, c, j): return commitment_quantities[c].iloc[j] @@ -191,8 +201,8 @@ def device_derivative_up_efficiency(m, d, j): 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) + model.up_price = Param(model.d, model.c, model.j, initialize=price_up_select) + model.down_price = Param(model.d, model.c, model.j, initialize=price_down_select) model.commitment_quantity = Param( model.c, model.j, initialize=commitment_quantity_select ) @@ -220,10 +230,10 @@ def device_derivative_up_efficiency(m, d, j): ) model.device_power_up = Var(model.d, model.j, domain=NonNegativeReals, initialize=0) model.commitment_downwards_deviation = Var( - model.c, model.j, domain=NonPositiveReals, initialize=0 + model.d, model.c, model.j, domain=NonPositiveReals, initialize=0 ) model.commitment_upwards_deviation = Var( - model.c, model.j, domain=NonNegativeReals, initialize=0 + model.d, model.c, model.j, domain=NonNegativeReals, initialize=0 ) # Add constraints as a tuple of (lower bound, value, upper bound) @@ -270,8 +280,8 @@ def ems_flow_commitment_equalities(m, j): return ( 0, sum(m.commitment_quantity[:, j]) - + sum(m.commitment_downwards_deviation[:, j]) - + sum(m.commitment_upwards_deviation[:, j]) + + sum(m.commitment_downwards_deviation[:, :, j]) + + sum(m.commitment_upwards_deviation[:, :, j]) - sum(m.ems_power[:, j]), 0, ) @@ -307,8 +317,15 @@ def cost_function(m): costs = 0 for c in m.c: for j in m.j: - costs += m.commitment_downwards_deviation[c, j] * m.down_price[c, j] - costs += m.commitment_upwards_deviation[c, j] * m.up_price[c, j] + for d in m.d: + costs += ( + m.commitment_downwards_deviation[d, c, j] + * m.down_price[d, c, j] + ) + costs += ( + m.commitment_upwards_deviation[d, c, j] + * m.up_price[d, c, j] + ) return costs model.costs = Objective(rule=cost_function, sense=minimize) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 74191180a..db3b4cb20 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -74,33 +74,50 @@ def compute(self, skip_validation: bool = False) -> pd.Series | None: soc_maxima = self.flex_model.get("soc_maxima") roundtrip_efficiency = self.flex_model.get("roundtrip_efficiency") prefer_charging_sooner = self.flex_model.get("prefer_charging_sooner", True) - consumption_price_sensor = self.flex_context.get("consumption_price_sensor") production_price_sensor = self.flex_context.get("production_price_sensor") + consumption_price_sensor_per_device = self.flex_context.get( + "consumption_price_sensor_per_device", {} + ) + production_price_sensor_per_device = self.flex_context.get( + "production_price_sensor_per_device", {} + ) inflexible_device_sensors = self.flex_context.get( "inflexible_device_sensors", [] ) + # Convert single price sensors to Multiple Price Sensors Dict + if consumption_price_sensor is not None: + consumption_price_sensor_per_device[sensor] = consumption_price_sensor + + if production_price_sensor is not None: + production_price_sensor_per_device[sensor] = production_price_sensor # Check for required Sensor attributes self.sensor.check_required_attributes([("capacity_in_mw", (float, int))]) # Check for known prices or price forecasts, trimming planning window accordingly - up_deviation_prices, (start, end) = get_prices( - (start, end), - resolution, - beliefs_before=belief_time, - price_sensor=consumption_price_sensor, - sensor=sensor, - allow_trimmed_query_window=False, - ) - 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=False, - ) + up_deviation_prices_array = [] + for power_sensor, price_sensor in consumption_price_sensor_per_device.items(): + up_deviation_prices, (start, end) = get_prices( + (start, end), + resolution, + beliefs_before=belief_time, + price_sensor=price_sensor, + sensor=power_sensor, + allow_trimmed_query_window=False, + ) + up_deviation_prices_array.append(up_deviation_prices) + down_deviation_prices_array = [] + for power_sensor, price_sensor in production_price_sensor_per_device.items(): + down_deviation_prices, (start, end) = get_prices( + (start, end), + resolution, + beliefs_before=belief_time, + price_sensor=price_sensor, + sensor=power_sensor, + allow_trimmed_query_window=False, + ) + down_deviation_prices_array.append(down_deviation_prices) start = pd.Timestamp(start).tz_convert("UTC") end = pd.Timestamp(end).tz_convert("UTC") @@ -108,23 +125,35 @@ def compute(self, skip_validation: bool = False) -> pd.Series | None: # 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: - up_deviation_prices = add_tiny_price_slope( - up_deviation_prices, "event_value" - ) - down_deviation_prices = add_tiny_price_slope( - down_deviation_prices, "event_value" - ) + for i in range(0, len(up_deviation_prices_array)): + up_deviation_prices_array[i] = add_tiny_price_slope( + up_deviation_prices_array[i], "event_value" + ) + for i in range(0, len(down_deviation_prices_array)): + down_deviation_prices_array[i] = add_tiny_price_slope( + down_deviation_prices_array[i], "event_value" + ) # Set up commitments to optimise for commitment_quantities = [initialize_series(0, start, end, self.resolution)] # Todo: convert to EUR/(deviation of commitment, which is in MW) - commitment_upwards_deviation_price = [ - up_deviation_prices.loc[start : end - resolution]["event_value"] - ] - commitment_downwards_deviation_price = [ - down_deviation_prices.loc[start : end - resolution]["event_value"] - ] + commitment_upwards_deviation_price_array = [] + for up_deviation_price in up_deviation_prices_array: + commitment_upwards_deviation_price = [ + up_deviation_price.loc[start : end - resolution]["event_value"] + ] + commitment_upwards_deviation_price_array.append( + commitment_upwards_deviation_price + ) + commitment_downwards_deviation_price_array = [] + for down_deviation_price in down_deviation_prices_array: + commitment_downwards_deviation_price = [ + down_deviation_price.loc[start : end - resolution]["event_value"] + ] + commitment_downwards_deviation_price_array.append( + commitment_downwards_deviation_price + ) # 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). device_constraints = [ @@ -197,8 +226,10 @@ def compute(self, skip_validation: bool = False) -> pd.Series | None: device_constraints, ems_constraints, commitment_quantities, - commitment_downwards_deviation_price, - commitment_upwards_deviation_price, + consumption_price_sensor_per_device, + production_price_sensor_per_device, + commitment_downwards_deviation_price_array, + commitment_upwards_deviation_price_array, ) if scheduler_results.solver.termination_condition == "infeasible": # Fallback policy if the problem was unsolvable diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 382f4430a..75fd0cc2e 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -13,3 +13,9 @@ class FlexContextSchema(Schema): inflexible_device_sensors = fields.List( SensorIdField(), data_key="inflexible-device-sensors" ) + consumption_price_sensor_per_device = fields.Dict( + SensorIdField(), SensorIdField(), data_key="consumption-price-sensor-per-device" + ) + production_price_sensor_per_device = fields.Dict( + SensorIdField(), SensorIdField(), data_key="production-price-sensor-per-device" + ) diff --git a/flexmeasures/data/tests/conftest.py b/flexmeasures/data/tests/conftest.py index 98540df7b..72862053a 100644 --- a/flexmeasures/data/tests/conftest.py +++ b/flexmeasures/data/tests/conftest.py @@ -1,8 +1,10 @@ +from __future__ import annotations import pytest from datetime import datetime, timedelta from random import random - +from timely_beliefs.sensors.func_store.knowledge_horizons import at_date from isodate import parse_duration +from flexmeasures.data.models.planning.utils import initialize_index import pandas as pd import numpy as np from flask_sqlalchemy import SQLAlchemy @@ -83,7 +85,7 @@ def setup_fresh_test_data( belief_horizon=parse_duration("PT0M"), event_value=val, sensor=asset.corresponding_sensor, - source=data_source, + source=DataSource("source1"), ) for dt, val in zip(time_slots, values) ] @@ -112,9 +114,6 @@ def fresh_remove_seasonality_for_power_forecasts(db, setup_asset_types_fresh_db) def add_test_weather_sensor_and_forecasts(db: SQLAlchemy, setup_generic_asset_types): """one day of test data (one complete sine curve) for two sensors""" - data_source = DataSource.query.filter_by( - name="Seita", type="demo script" - ).one_or_none() weather_station = GenericAsset( name="Test weather station farther away", generic_asset_type=setup_generic_asset_types["weather_station"], @@ -141,7 +140,7 @@ def add_test_weather_sensor_and_forecasts(db: SQLAlchemy, setup_generic_asset_ty event_start=as_server_time(dt), event_value=val, belief_horizon=timedelta(hours=6), - source=data_source, + source=DataSource("source1"), ) ) @@ -232,3 +231,350 @@ def setup_annotations( asset=asset, sensor=sensor, ) + + +@pytest.fixture(scope="module") +def create_solar_plants(db, setup_accounts, setup_sources) -> dict[str, Sensor]: + """Create Solar Plants and their Power and Price sensor along with their beliefs.""" + asset_type = GenericAssetType( + name="Solar", + ) + db.session.add(asset_type) + Solar1 = GenericAsset( + name="solar-1", + generic_asset_type=asset_type, + ) + db.session.add(Solar1) + solar1_production_price_sensor = Sensor( + name="solar1-production-price-sensor", + generic_asset=Solar1, + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon=(at_date, {"knowledge_time": "2014-11-01T00:00+01:00"}), + ) + db.session.add(solar1_production_price_sensor) + production_price = TimedBelief( + event_start="2015-01-01T00:00+01:00", + belief_time="2014-11-01T00:00+01:00", # publication date + event_value=2, + source=setup_sources["Seita"], + sensor=solar1_production_price_sensor, + ) + db.session.add(production_price) + solar1_power_sensor = Sensor( + name="solar-power-1", + generic_asset=Solar1, + event_resolution=timedelta(hours=1), + unit="MW", + attributes={"capacity_in_mw": 2000}, + ) + db.session.add(solar1_power_sensor) + time_slots = initialize_index( + start=pd.Timestamp("2015-01-01").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-02").tz_localize("Europe/Amsterdam"), + resolution="1H", + ) + values = [0] * 5 + list(range(7, 51, 7)) + list(range(50, 0, -7)) + [0] * 5 + add_as_beliefs(db, solar1_power_sensor, values, time_slots, setup_sources["Seita"]) + Solar2 = GenericAsset( + name="solar-2", + generic_asset_type=asset_type, + ) + db.session.add(Solar2) + solar2_production_price_sensor = Sensor( + name="solar2-production-price-sensor", + generic_asset=Solar2, + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon=(at_date, {"knowledge_time": "2014-11-01T00:00+01:00"}), + ) + db.session.add(solar2_production_price_sensor) + production_price = TimedBelief( + event_start="2015-01-01T00:00+01:00", + belief_time="2014-11-01T00:00+01:00", # publication date + event_value=2.5, + source=setup_sources["Seita"], + sensor=solar2_production_price_sensor, + ) + db.session.add(production_price) + solar2_power_sensor = Sensor( + name="solar-power-2", + generic_asset=Solar2, + event_resolution=timedelta(hours=1), + unit="MW", + attributes={"capacity_in_mw": 2000}, + ) + db.session.add(solar2_power_sensor) + time_slots = initialize_index( + start=pd.Timestamp("2015-01-01").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-02").tz_localize("Europe/Amsterdam"), + resolution="1H", + ) + values = [0] * 5 + list(range(7, 51, 7)) + list(range(50, 0, -7)) + [0] * 5 + add_as_beliefs(db, solar2_power_sensor, values, time_slots, setup_sources["Seita"]) + Solar3 = GenericAsset( + name="solar-3", + generic_asset_type=asset_type, + ) + db.session.add(Solar3) + solar3_production_price_sensor = Sensor( + name="solar3-production-price-sensor", + generic_asset=Solar3, + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon=(at_date, {"knowledge_time": "2014-11-01T00:00+01:00"}), + ) + db.session.add(solar3_production_price_sensor) + production_price = TimedBelief( + event_start="2015-01-01T00:00+01:00", + belief_time="2014-11-01T00:00+01:00", # publication date + event_value=3, + source=setup_sources["Seita"], + sensor=solar3_production_price_sensor, + ) + db.session.add(production_price) + solar3_power_sensor = Sensor( + name="solar-power-3", + generic_asset=Solar3, + event_resolution=timedelta(hours=1), + unit="MW", + attributes={"capacity_in_mw": 2000}, + ) + db.session.add(solar3_power_sensor) + time_slots = initialize_index( + start=pd.Timestamp("2015-01-01").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-02").tz_localize("Europe/Amsterdam"), + resolution="1H", + ) + values = [0] * 5 + list(range(7, 51, 7)) + list(range(50, 0, -7)) + [0] * 5 + add_as_beliefs( + db, solar3_power_sensor, values, time_slots, setup_sources["Seita"] + ) # make sure that prices are assigned to price sensors + db.session.flush() + return ( + solar1_production_price_sensor, + solar1_power_sensor, + solar2_production_price_sensor, + solar2_power_sensor, + solar3_production_price_sensor, + solar3_power_sensor, + ) + + +@pytest.fixture(scope="module") +def create_building(db, setup_sources) -> dict[str, Sensor]: + """ + Set up a building. + """ + asset_type = GenericAssetType( + name="Building", + ) + db.session.add(asset_type) + Building = GenericAsset( + name="building", + generic_asset_type=asset_type, + ) + db.session.add(Building) + building_consumption_price_sensor = Sensor( + name="building-consumption-price-sensor", + generic_asset=Building, + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon=(at_date, {"knowledge_time": "2014-11-01T00:00+01:00"}), + ) + db.session.add(building_consumption_price_sensor) + building_power = Sensor( + name="building-power", + generic_asset=Building, + event_resolution=timedelta(hours=1), + unit="MW", + attributes={"capacity_in_mw": 2000}, + ) + db.session.add(building_power) + time_slots = initialize_index( + start=pd.Timestamp("2015-01-01").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-02").tz_localize("Europe/Amsterdam"), + resolution="1H", + ) + values = ( + [-30] * 1 + + list(range(-50, -210, -30)) + + list(range(-197, -40, 30)) + + list(range(-50, -210, -30)) + + list(range(-197, -70, 30)) + ) + add_as_beliefs(db, building_power, values, time_slots, setup_sources["Seita"]) + db.session.flush() + return building_consumption_price_sensor, building_power + + +@pytest.fixture(scope="module") +def flexible_devices(db, setup_sources) -> dict[str, Sensor]: + """ + Set up sensors for flexible devices: + - Battery + - Transmission Grid + """ + asset_type = GenericAssetType( + name="test-Battery", + ) + db.session.add(asset_type) + Battery = GenericAsset( + name="battery", + generic_asset_type=asset_type, + attributes=dict( + capacity_in_mw=800, + max_soc_in_mwh=795, + min_soc_in_mwh=0.5, + ), + ) + db.session.add(Battery) + battery_consumption_price_sensor = Sensor( + name="battery-consumption-price-sensor", + generic_asset=Battery, + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon=(at_date, {"knowledge_time": "2014-11-01T00:00+01:00"}), + ) + db.session.add(battery_consumption_price_sensor) + battery_production_price_sensor = Sensor( + name="battery-production-price-sensor", + generic_asset=Battery, + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon=(at_date, {"knowledge_time": "2014-11-01T00:00+01:00"}), + ) + db.session.add(battery_production_price_sensor) + battery_power = Sensor( + name="battery-power", + generic_asset=Battery, + event_resolution=timedelta(hours=1), + unit="MW", + attributes={"capacity_in_mw": 2000}, + ) + db.session.add(battery_power) + asset_type = GenericAssetType( + name="Transmission-Grid", + ) + db.session.add(asset_type) + Grid = GenericAsset( + name="grid", + generic_asset_type=asset_type, + ) + db.session.add(Grid) + grid_consumption_price_sensor = Sensor( + name="grid-consumption-price-sensor", + generic_asset=Grid, + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon=(at_date, {"knowledge_time": "2014-11-01T00:00+01:00"}), + ) + db.session.add(grid_consumption_price_sensor) + time_slots = initialize_index( + start=pd.Timestamp("2015-01-01").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-02").tz_localize("Europe/Amsterdam"), + resolution="1H", + ) + values = [ + 9.63, + 8.66, + 8.387, + 8.387, + 9.6, + 9.722, + 9.907, + 11.777, + 10.237, + 7.999, + 7.08, + 6.5, + 5.999, + 5.233, + 5, + 5, + 4.5, + 5.03, + 5.8, + 7.105, + 10.012, + 12.494, + 11.825, + 10.396, + ] + add_as_beliefs( + db, grid_consumption_price_sensor, values, time_slots, setup_sources["Seita"] + ) + grid_production_price_sensor = Sensor( + name="grid-production-price-sensor", + generic_asset=Grid, + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon=(at_date, {"knowledge_time": "2014-11-01T00:00+01:00"}), + ) + db.session.add(grid_production_price_sensor) + time_slots = initialize_index( + start=pd.Timestamp("2015-01-01").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-02").tz_localize("Europe/Amsterdam"), + resolution="1H", + ) + values = [ + 9.63, + 8.66, + 8.387, + 8.387, + 9.6, + 9.722, + 9.907, + 11.777, + 10.237, + 7.999, + 7.08, + 6.5, + 5.999, + 5.233, + 5, + 5, + 4.5, + 5.03, + 5.8, + 7.105, + 10.012, + 12.494, + 11.825, + 10.396, + ] + add_as_beliefs( + db, grid_production_price_sensor, values, time_slots, setup_sources["Seita"] + ) + grid_power = Sensor( + name="Grid-power", + generic_asset=Grid, + event_resolution=timedelta(hours=1), + unit="MW", + attributes={"capacity_in_mw": 20000}, + ) + db.session.add(grid_power) + db.session.flush() + return ( + battery_consumption_price_sensor, + battery_production_price_sensor, + battery_power, + grid_consumption_price_sensor, + grid_production_price_sensor, + grid_power, + ) + + +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) + db.session.commit() diff --git a/flexmeasures/data/tests/test_dict_price_sensors.py b/flexmeasures/data/tests/test_dict_price_sensors.py new file mode 100644 index 000000000..879999561 --- /dev/null +++ b/flexmeasures/data/tests/test_dict_price_sensors.py @@ -0,0 +1,94 @@ +from datetime import datetime, timedelta +import pytest +import pytz +from flexmeasures.data.models.planning.storage import ( + StorageScheduler, +) +from flexmeasures.utils.calculations import integrate_time_series + + +TOLERANCE = 0.00001 + + +@pytest.mark.parametrize( + "roundtrip_efficiency", + [ + 0.90, + 0.95, + ], +) +def test_schedule_multiple_price_sensors( + create_solar_plants, create_building, flexible_devices, roundtrip_efficiency: float +): + """ + Using a dictionary of price sensors mapped to different devices to schedule the battery and check it's scheduling results for tomorrow. + """ + ( + solar1_production_price_sensor, + solar1_power_sensor, + solar2_production_price_sensor, + solar2_power_sensor, + solar3_production_price_sensor, + solar3_power_sensor, + ) = create_solar_plants + building_consumption_price_sensor, building_power = create_building + ( + battery_consumption_price_sensor, + battery_production_price_sensor, + battery, + grid_consumption_price_sensor, + grid_production_price_sensor, + grid_power, + ) = flexible_devices + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 1)) + end = tz.localize(datetime(2015, 1, 2)) + resolution = timedelta(minutes=60) + soc_at_start = 545 + soc_min = 0.5 + soc_max = 795 + inflexible_devices = [ + solar1_power_sensor.id, + building_power.id, + ] + consumption_price_sensor_per_device = { + grid_power.id: grid_consumption_price_sensor.id + } + production_price_sensor_per_device = { + solar1_power_sensor.id: solar1_production_price_sensor.id, + grid_power.id: grid_production_price_sensor.id, + } + scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model={ + "soc-at-start": soc_at_start, + "soc-unit": "MWh", + "soc-min": soc_min, + "soc-max": soc_max, + "roundtrip-efficiency": roundtrip_efficiency, + }, + flex_context={ + "inflexible-device-sensors": inflexible_devices, + "consumption-price-sensor-per-device": consumption_price_sensor_per_device, + "production-price-sensor-per-device": production_price_sensor_per_device, + }, + ) + schedule = scheduler.compute() + soc_schedule = integrate_time_series( + schedule, + soc_at_start, + up_efficiency=roundtrip_efficiency**0.5, + down_efficiency=roundtrip_efficiency**0.5, + decimal_precision=5, + ) + # Check if constraints were met + assert ( + min(schedule.values) >= battery.get_attribute("capacity_in_mw") * -1 - TOLERANCE + ) + assert max(schedule.values) <= battery.get_attribute("capacity_in_mw") + for soc in soc_schedule.values: + assert soc >= battery.get_attribute("min_soc_in_mwh") + assert soc <= battery.get_attribute("max_soc_in_mwh")