diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 235c17842..f4a9badf3 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -5,6 +5,11 @@ API change log .. note:: The FlexMeasures API follows its own versioning scheme. This is also reflected in the URL (e.g. `/api/v3_0`), allowing developers to upgrade at their own pace. +v3.0-12 | 2023-09-20 +"""""""""""""""""""" + +- Introduced the ``power-capacity`` field under ``flex-model``, and the ``site-power-capacity`` field under ``flex-context``, for `/sensors//schedules/trigger` (POST). + v3.0-11 | 2023-08-02 """""""""""""""""""" diff --git a/documentation/api/notation.rst b/documentation/api/notation.rst index 63606ee27..c3b6d6bd4 100644 --- a/documentation/api/notation.rst +++ b/documentation/api/notation.rst @@ -197,6 +197,7 @@ Here are the three types of flexibility models you can expect to be built-in: - ``roundtrip-efficiency`` (defaults to 100%) - ``storage-efficiency`` (defaults to 100%) [#]_ - ``prefer-charging-sooner`` (defaults to True, also signals a preference to discharge later) + - ``power-capacity`` (defaults to the Sensor attribute ``capacity_in_mw``) .. [#] The storage efficiency (e.g. 95% or 0.95) to use for the schedule is applied over each time step equal to the sensor resolution. For example, a storage efficiency of 95 percent per (absolute) day, for scheduling a 1-hour resolution sensor, should be passed as a storage efficiency of :math:`0.95^{1/24} = 0.997865`. @@ -236,6 +237,7 @@ With the flexibility context, we aim to describe the system in which the flexibl - ``inflexible-device-sensors`` ― power sensors that are relevant, but not flexible, such as a sensor recording rooftop solar power connected behind the main meter, whose production falls under the same contract as the flexible device(s) being scheduled - ``consumption-price-sensor`` ― the sensor which defines costs/revenues of consuming energy - ``production-price-sensor`` ― the sensor which defines cost/revenues of producing energy +- ``site-power-capacity`` (defaults to the Asset attribute ``capacity_in_mw``) These should be independent on the asset type and consequently also do not depend on which scheduling algorithm is being used. diff --git a/documentation/changelog.rst b/documentation/changelog.rst index f83f8bbe0..edd8da303 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -11,6 +11,7 @@ New features * Introduce new reporter to compute profit/loss due to electricity flows: `ProfitOrLossReporter` [see `PR #808 `_ and `PR #844 `_] * Charts visible in the UI can be exported to PNG or SVG formats in a more automated fashion, using the new CLI command flexmeasures show chart [see `PR #833 `_] +* API users can ask for a schedule to take into account an explicit ``power-capacity`` (flex-model) and/or ``site-power-capacity`` (flex-context), thereby overriding any existing defaults for their asset [see `PR #850 `_] * Sensor charts showing instantaneous observations can be interpolated by setting the ``interpolate`` sensor attribute to one of the `supported Vega-Lite interpolation methods `_ [see `PR #851 `_] Infrastructure / Support diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 851fbbed8..3105fed86 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -313,11 +313,13 @@ def trigger_schedule( # noqa: C901 "soc-max": 25, "roundtrip-efficiency": 0.98, "storage-efficiency": 0.9999, + "power-capacity" : "25kW" }, "flex-context": { "consumption-price-sensor": 9, "production-price-sensor": 10, - "inflexible-device-sensors": [13, 14, 15] + "inflexible-device-sensors": [13, 14, 15], + "site-power-capacity": "100kW" } } diff --git a/flexmeasures/api/v3_0/tests/test_sensor_schedules.py b/flexmeasures/api/v3_0/tests/test_sensor_schedules.py index d03f4091e..d3bec8e5c 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_schedules.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_schedules.py @@ -177,11 +177,12 @@ def test_trigger_and_get_schedule( message, asset_name, ): - # Include the price sensor in the flex-context explicitly, to test deserialization + # Include the price sensor and site-power-capacity in the flex-context explicitly, to test deserialization price_sensor_id = add_market_prices["epex_da"].id message["flex-context"] = { "consumption-price-sensor": price_sensor_id, "production-price-sensor": price_sensor_id, + "site-power-capacity": "1 TW", # should be big enough to avoid any infeasibilities } # trigger a schedule through the /sensors//schedules/trigger [POST] api endpoint diff --git a/flexmeasures/api/v3_0/tests/utils.py b/flexmeasures/api/v3_0/tests/utils.py index c39291f0b..445a8fcd3 100644 --- a/flexmeasures/api/v3_0/tests/utils.py +++ b/flexmeasures/api/v3_0/tests/utils.py @@ -73,6 +73,7 @@ def message_for_trigger_schedule( "soc-unit": "kWh", "roundtrip-efficiency": "98%", "storage-efficiency": "99.99%", + "power-capacity": "2 MW", # same as capacity_in_mw attribute of test battery and test charging station } if with_targets: if realistic_targets: diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 227b0712c..b730a6f26 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -22,6 +22,7 @@ from flexmeasures.data.schemas.scheduling import FlexContextSchema from flexmeasures.utils.time_utils import get_max_planning_horizon from flexmeasures.utils.coding_utils import deprecated +from flexmeasures.utils.unit_utils import ur class StorageScheduler(Scheduler): @@ -50,7 +51,7 @@ def compute_schedule(self) -> pd.Series | None: return self.compute() - def _prepare(self, skip_validation: bool = False) -> tuple: + def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 """This function prepares the required data to compute the schedule: - price data - device constraint @@ -86,7 +87,26 @@ def _prepare(self, skip_validation: bool = False) -> tuple: ) # Check for required Sensor attributes - self.sensor.check_required_attributes([("capacity_in_mw", (float, int))]) + power_capacity_in_mw = self.flex_model.get( + "power_capacity_in_mw", + self.sensor.get_attribute("capacity_in_mw", None), + ) + + if power_capacity_in_mw is None: + raise ValueError( + "Power capacity is not defined in the sensor attributes or the flex-model." + ) + + if isinstance(power_capacity_in_mw, ur.Quantity): + power_capacity_in_mw = power_capacity_in_mw.magnitude + + if not ( + isinstance(power_capacity_in_mw, float) + or isinstance(power_capacity_in_mw, int) + ): + raise ValueError( + "The only supported types for the power capacity are int and float." + ) # Check for known prices or price forecasts, trimming planning window accordingly up_deviation_prices, (start, end) = get_prices( @@ -158,15 +178,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: if sensor.get_attribute("is_strictly_non_positive"): device_constraints[0]["derivative min"] = 0 else: - device_constraints[0]["derivative min"] = ( - sensor.get_attribute("capacity_in_mw") * -1 - ) + device_constraints[0]["derivative min"] = power_capacity_in_mw * -1 if sensor.get_attribute("is_strictly_non_negative"): device_constraints[0]["derivative max"] = 0 else: - device_constraints[0]["derivative max"] = sensor.get_attribute( - "capacity_in_mw" - ) + device_constraints[0]["derivative max"] = power_capacity_in_mw # Apply round-trip efficiency evenly to charging and discharging device_constraints[0]["derivative down efficiency"] = ( @@ -199,10 +215,26 @@ def _prepare(self, skip_validation: bool = False) -> tuple: ems_constraints = initialize_df( StorageScheduler.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_power_capacity_in_mw = self.flex_context.get( + "ems_power_capacity_in_mw", + self.sensor.generic_asset.get_attribute("capacity_in_mw", None), + ) + + if ems_power_capacity_in_mw is not None: + if isinstance(ems_power_capacity_in_mw, ur.Quantity): + ems_power_capacity_in_mw = ems_power_capacity_in_mw.magnitude + + if not ( + isinstance(ems_power_capacity_in_mw, float) + or isinstance(ems_power_capacity_in_mw, int) + ): + raise ValueError( + "The only supported types for the ems power capacity are int and float." + ) + + ems_constraints["derivative min"] = ems_power_capacity_in_mw * -1 + ems_constraints["derivative max"] = ems_power_capacity_in_mw return ( sensor, diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 9719accd8..4091ded70 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1064,3 +1064,71 @@ def test_numerical_errors(app, setup_planning_test_data, solver): assert device_constraints[0]["equals"].max() > device_constraints[0]["max"].max() assert device_constraints[0]["equals"].min() < device_constraints[0]["min"].min() assert results.solver.status == "ok" + + +@pytest.mark.parametrize( + "capacity,site_capacity", + [("100kW", "300kW"), ("0.1MW", "0.3MW"), ("0.1 MW", "0.3 MW"), (None, None)], +) +def test_capacity(app, setup_planning_test_data, capacity, site_capacity): + """Test that the power limits of the site and storage device are set properly using the + flex-model and flex-context. + """ + + expected_capacity = 2 + expected_site_capacity = 2 + + flex_model = { + "soc-at-start": 0.01, + } + + flex_context = {} + + if capacity is not None: + flex_model["power-capacity"] = capacity + expected_capacity = 0.1 + + if site_capacity is not None: + flex_context["site-power-capacity"] = site_capacity + expected_site_capacity = 0.3 + + epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() + charging_station = setup_planning_test_data[ + "Test charging station (bidirectional)" + ].sensors[0] + + assert charging_station.generic_asset.get_attribute("capacity_in_mw") == 2 + 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=5) + + scheduler = StorageScheduler( + charging_station, + start, + end, + resolution, + flex_model=flex_model, + flex_context=flex_context, + ) + + ( + sensor, + start, + end, + resolution, + soc_at_start, + device_constraints, + ems_constraints, + commitment_quantities, + commitment_downwards_deviation_price, + commitment_upwards_deviation_price, + ) = scheduler._prepare(skip_validation=True) + + assert all(device_constraints[0]["derivative min"] == -expected_capacity) + assert all(device_constraints[0]["derivative max"] == expected_capacity) + + assert all(ems_constraints["derivative min"] == -expected_site_capacity) + assert all(ems_constraints["derivative max"] == expected_site_capacity) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 382f4430a..30e76b7f3 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -1,6 +1,7 @@ from marshmallow import Schema, fields from flexmeasures.data.schemas.sensors import SensorIdField +from flexmeasures.data.schemas.units import QuantityField class FlexContextSchema(Schema): @@ -8,6 +9,9 @@ class FlexContextSchema(Schema): This schema lists fields that can be used to describe sensors in the optimised portfolio """ + ems_power_capacity_in_mw = QuantityField( + "MW", required=False, data_key="site-power-capacity" + ) consumption_price_sensor = SensorIdField(data_key="consumption-price-sensor") production_price_sensor = SensorIdField(data_key="production-price-sensor") inflexible_device_sensors = fields.List( diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 25f840900..55088c205 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -77,6 +77,10 @@ class StorageFlexModelSchema(Schema): soc_min = fields.Float(validate=validate.Range(min=0), data_key="soc-min") soc_max = fields.Float(data_key="soc-max") + power_capacity_in_mw = QuantityField( + "MW", required=False, data_key="power-capacity" + ) + soc_maxima = fields.List(fields.Nested(SOCValueSchema()), data_key="soc-maxima") soc_minima = fields.List( fields.Nested(SOCValueSchema(value_validator=validate.Range(min=0))),