diff --git a/documentation/dev/setup-and-guidelines.rst b/documentation/dev/setup-and-guidelines.rst index 3b7ae6e246..d5d9b7458d 100644 --- a/documentation/dev/setup-and-guidelines.rst +++ b/documentation/dev/setup-and-guidelines.rst @@ -63,7 +63,7 @@ On Linux and Windows, everything will be installed using Python packages. On MacOS, this will install all test dependencies, and locally install the HiGHS solver. For this to work, make sure you have `Homebrew `_ installed. -Besides the HiGHS solver (as the current default), the CBC solver is required for tests as well. See `The install instructions `_ for more information. Configuration ^^^^^^^^^^^^^ diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 6fb7aa5e94..5a22572311 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -198,17 +198,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 start = pd.Timestamp(start).tz_convert("UTC") end = pd.Timestamp(end).tz_convert("UTC") - # Add tiny price slope to prefer charging now rather than later, and discharging later rather than now. - # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. - # todo: move to flow or stock commitment per device - if any(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" - ) - # Create Series with EMS capacities ems_power_capacity_in_mw = get_continuous_series_sensor_or_quantity( variable_quantity=self.flex_context.get("ems_power_capacity_in_mw"), @@ -441,23 +430,32 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Take the contracted capacity as a hard constraint ems_constraints["derivative min"] = ems_production_capacity - # Flow commitments per device + # Commitments per device - # Add tiny price slope to prefer curtailing later rather than now. - # The price slope is half of the slope to prefer charging sooner - for d, prefer_curtailing_later_d in enumerate(prefer_curtailing_later): - if prefer_curtailing_later_d: + # StockCommitment per device to prefer a full storage by penalizing not being full + # This corresponds to a preference for charging now rather than later, and discharging later rather than now. + for d, (prefer_charging_sooner_d, prefer_curtailing_later_d) in enumerate( + zip(prefer_charging_sooner, prefer_curtailing_later) + ): + if prefer_charging_sooner_d: tiny_price_slope = ( - add_tiny_price_slope(up_deviation_prices, "event_value") + add_tiny_price_slope( + up_deviation_prices, "event_value", order="desc" + ) - up_deviation_prices ) - tiny_price_slope *= 0.5 - commitment = FlowCommitment( - name=f"prefer curtailing device {d} later", - # Prefer curtailing consumption later by penalizing later consumption - upwards_deviation_price=tiny_price_slope, - # Prefer curtailing production later by penalizing later production - downwards_deviation_price=-tiny_price_slope, + if prefer_curtailing_later: + # Use a tiny price slope to prefer a fuller SoC sooner rather than later, by lowering penalties later + penalty = tiny_price_slope + else: + # Constant penalty + penalty = tiny_price_slope[0] + commitment = StockCommitment( + name=f"prefer a full storage {d} sooner", + quantity=(soc_max[d] - soc_at_start[d]) + * (timedelta(hours=1) / resolution), + upwards_deviation_price=0, + downwards_deviation_price=-penalty, index=index, device=d, ) @@ -940,7 +938,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 def convert_to_commitments( self, **timing_kwargs, - ) -> list[FlowCommitment]: + ) -> list[FlowCommitment | StockCommitment]: """Convert list of commitment specifications (dicts) to a list of FlowCommitments.""" commitment_specs = self.flex_context.get("commitments", []) if len(commitment_specs) == 0: diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 919936d315..a45a3dd2d9 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -270,6 +270,7 @@ def run_test_charge_discharge_sign( for soc_at_start_d in soc_at_start ], ) + assert results.solver.termination_condition == "optimal" device_power_sign = pd.Series(model.device_power_sign.extract_values())[0] device_power_up = pd.Series(model.device_power_up.extract_values())[0] @@ -816,8 +817,8 @@ def compute_schedule(flex_model): # soc maxima and soc minima soc_maxima = [ - {"datetime": "2015-01-02T15:00:00+01:00", "value": 1.0}, - {"datetime": "2015-01-02T16:00:00+01:00", "value": 1.0}, + {"datetime": "2015-01-02T12:00:00+01:00", "value": 1.0}, + {"datetime": "2015-01-02T13:00:00+01:00", "value": 1.0}, ] soc_minima = [{"datetime": "2015-01-02T08:00:00+01:00", "value": 3.5}] @@ -853,7 +854,7 @@ def compute_schedule(flex_model): # test for soc_maxima # check that the local maximum constraint is respected - assert soc_schedule_2.loc["2015-01-02T15:00:00+01:00"] <= 1.0 + assert soc_schedule_2.loc["2015-01-02T13:00:00+01:00"] <= 1.0 # test for soc_targets # check that the SOC target (at 19 pm, local time) is met @@ -1787,10 +1788,10 @@ def test_battery_stock_delta_sensor( - Battery of size 2 MWh. - Consumption capacity of the battery is 2 MW. - The battery cannot discharge. - With these settings, the battery needs to charge at a power or greater than the usage forecast + With these settings, the battery needs to charge at a power equal or greater than the usage forecast to keep the SOC within bounds ([0, 2 MWh]). """ - _, battery = get_sensors_from_db(db, add_battery_assets) + epex_da, battery = get_sensors_from_db(db, add_battery_assets) tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1835,9 +1836,20 @@ def test_battery_stock_delta_sensor( with pytest.raises(InfeasibleProblemException): scheduler.compute() elif stock_delta_sensor is None: - # No usage -> the battery does not charge + # No usage -> the battery only charges when energy is free + free_hour = "2015-01-01 17:00:00+00:00" + prices = epex_da.search_beliefs(start, end, resolution=resolution) + zero_prices = prices[prices.event_value == 0] + assert all( + zero_prices.event_starts.hour == pd.Timestamp(free_hour).hour + ), "this test assumes a single hour of free energy from 5 to 6 PM UTC" schedule = scheduler.compute() - assert all(schedule == 0) + assert all( + schedule[~schedule.index.isin(zero_prices.event_starts)] == 0 + ), "no charging expected when energy is not free, given no soc-usage" + assert all( + schedule[schedule.index.isin(zero_prices.event_starts)] == capacity + ), "max charging expected when energy is free, because of preference to have a full SoC" else: # Some usage -> the battery needs to charge schedule = scheduler.compute() @@ -2235,11 +2247,14 @@ def test_battery_storage_different_units( battery_name="Test battery", power_sensor_name=power_sensor_name, ) - tz = pytz.timezone("Europe/Amsterdam") + tz = pytz.timezone(epex_da.timezone) # transition from cheap to expensive (90 -> 100) start = tz.localize(datetime(2015, 1, 2, 14, 0, 0)) end = tz.localize(datetime(2015, 1, 2, 16, 0, 0)) + assert len(epex_da.search_beliefs(start, end)) == 2 + assert epex_da.search_beliefs(start, end).values[0][0] == 90 + assert epex_da.search_beliefs(start, end).values[1][0] == 100 resolution = timedelta(minutes=15) flex_model = { diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 549ef6a01a..c7e20860f0 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -2,6 +2,7 @@ from packaging import version from datetime import date, datetime, timedelta +from typing import Literal from flask import current_app import pandas as pd @@ -67,7 +68,10 @@ def initialize_index( def add_tiny_price_slope( - orig_prices: pd.DataFrame, col_name: str = "event_value", d: float = 10**-4 + orig_prices: pd.DataFrame, + col_name: str = "event_value", + d: float = 10**-4, + order: Literal["asc", "desc"] = "asc", ) -> pd.DataFrame: """Add tiny price slope to col_name to represent e.g. inflation as a simple linear price increase. This is meant to break ties, when multiple time slots have equal prices, in favour of acting sooner. @@ -79,9 +83,16 @@ def add_tiny_price_slope( max_penalty = price_spread * d else: max_penalty = d - prices[col_name] = prices[col_name] + np.linspace( - 0, max_penalty, prices[col_name].size - ) + if order == "asc": + prices[col_name] = prices[col_name] + np.linspace( + 0, max_penalty, prices[col_name].size + ) + elif order == "desc": + prices[col_name] = prices[col_name] + np.linspace( + max_penalty, 0, prices[col_name].size + ) + else: + raise ValueError("order must be 'asc' or 'desc'") return prices