From e0af47a09c66fb7f4135324b38cc357ada336527 Mon Sep 17 00:00:00 2001 From: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Date: Mon, 3 Jan 2022 14:40:57 +0100 Subject: [PATCH] Issue 273 add roundtrip efficiency to scheduler (#291) Rewrite our generic device scheduler to: - Deal with asymmetric efficiency losses of individual devices. - Deal with asymmetric up and down prices for deviating from previous commitments. Also allow round-trip efficiency to be communicated as a new optional field when POSTing UDI Events, with efficiency losses being assigned equally to charging and discharging. * Query TimedBelief rather than Power in api v1.3 tests Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power in api v1.3 implementations Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power in user services tests Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power in query tests Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power in forecasting tests Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power in scheduling tests Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power in api v1 tests Signed-off-by: F.N. Claessen * Simplify data deletion, like, by a lot Signed-off-by: F.N. Claessen * Count ex-ante TimedBeliefs after populating time series forecasts Signed-off-by: F.N. Claessen * Query TimedBelief rather than Price in api v1_1 tests Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power/Price/Weather in Resource.load_sensor_data Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power/Price/Weather in api v2.0 tests Signed-off-by: F.N. Claessen * Refactor: simplify duplicate query construction Signed-off-by: F.N. Claessen * Add custom join target to get rid of SA warning Signed-off-by: F.N. Claessen * Filter criteria should work for both TimedBeliefs and TimedValues Signed-off-by: F.N. Claessen * Clarify docstring Signed-off-by: F.N. Claessen * Query TimedBelief rather than Power in api v1 implementations Signed-off-by: F.N. Claessen * Schedules should contain one deterministic belief per event Signed-off-by: F.N. Claessen * Fix type annotation Signed-off-by: F.N. Claessen * flake8 Signed-off-by: F.N. Claessen * Query TimedBelief rather than Price/Weather for analytics Signed-off-by: F.N. Claessen * Query deterministic TimedBelief rather than Price for planning queries Signed-off-by: F.N. Claessen * Forecast TimedBelief rather than Power/Price/Weather Signed-off-by: F.N. Claessen * Schedule TimedBelief rather than Power Signed-off-by: F.N. Claessen * Apparently, to initialize a TimedBelief is to save a TimedBelief, too Signed-off-by: F.N. Claessen * Create TimedBelief rather than Power/Price/Weather in data generation script Signed-off-by: F.N. Claessen * Bump timely-beliefs dependency Signed-off-by: F.N. Claessen * Fix latest state query Signed-off-by: F.N. Claessen * Revert "Apparently, to initialize a TimedBelief is to save a TimedBelief, too" This reverts commit fb58ec7459dd69c571bee27cdce61e67c14617ae. * Prevent saving TimedBelief to session upon updating Sensor or Source Signed-off-by: F.N. Claessen * Create only TimedBeliefs in conftests Signed-off-by: F.N. Claessen * Use session.add_all calls instead of session.bulk_save_objects or individual session.add calls Signed-off-by: F.N. Claessen * API directly creates TimedBeliefs Signed-off-by: F.N. Claessen * CLI uses TimedBeliefs only Signed-off-by: F.N. Claessen * Data scripts use TimedBeliefs only Signed-off-by: F.N. Claessen * One more conftest switched to creating TimedBeliefs instead of Weather objects Signed-off-by: F.N. Claessen * Expand docstring note on forbidden replacements Signed-off-by: F.N. Claessen * Clarify docstring note on saving changed beliefs only Signed-off-by: F.N. Claessen * Remove redundant flush Signed-off-by: F.N. Claessen * Catch forbidden belief replacements with more specific exception Signed-off-by: F.N. Claessen * Rename variable Signed-off-by: F.N. Claessen * One transaction per request Signed-off-by: F.N. Claessen * Only enqueue forecasting jobs upon successfully saving new data Signed-off-by: F.N. Claessen * Flush instead of commit Signed-off-by: F.N. Claessen * Expand test for forbidden data replacement Signed-off-by: F.N. Claessen * Simplify play mode excemption for replacing beliefs Signed-off-by: F.N. Claessen * Add note about potential session rollback Signed-off-by: F.N. Claessen * Typo Signed-off-by: F.N. Claessen * Move UniqueViolation catching logic to error handler Signed-off-by: F.N. Claessen * flake8 Signed-off-by: F.N. Claessen * Rewrite solver to deal with asymmetry in up and down commitment prices Signed-off-by: F.N. Claessen * Add optional roundtrip_efficiency field to UDI events, and use it to scale prices Signed-off-by: F.N. Claessen * Add test cases for various round-trip efficiencies Signed-off-by: F.N. Claessen * Add changelog entries Signed-off-by: F.N. Claessen * Add documentation for the new API field Signed-off-by: F.N. Claessen * Grammar corrections Signed-off-by: F.N. Claessen * Fix return value for empty EMS Signed-off-by: F.N. Claessen * Allow efficiencies per device for multi-device EMS, by stopping the application of round-trip efficiency as price scalars and modeling device flows in more detail Signed-off-by: F.N. Claessen * Relax tests using some tolerance Signed-off-by: F.N. Claessen * Fix mistake Signed-off-by: F.N. Claessen * Add test docstring Signed-off-by: F.N. Claessen * Check round-trip efficiency for acceptable range Signed-off-by: F.N. Claessen * Expand docstring Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 8 ++ documentation/changelog.rst | 1 + flexmeasures/api/v1_3/implementations.py | 4 + flexmeasures/api/v1_3/routes.py | 4 +- flexmeasures/data/models/planning/battery.py | 14 +++ .../data/models/planning/charging_station.py | 14 ++- flexmeasures/data/models/planning/solver.py | 116 +++++++++++++++--- .../data/models/planning/tests/test_solver.py | 70 ++++++++--- flexmeasures/data/services/scheduling.py | 19 ++- 9 files changed, 216 insertions(+), 34 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 4a76a5a54..d6f119230 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -39,6 +39,14 @@ v2.0-0 | 2020-11-14 - REST endpoints for managing assets: `/assets/` (GET, POST) and `/asset/` (GET, PATCH, DELETE). +v1.3-11 | 2022-01-01 +"""""""""""""""""""" + +*Affects all versions since v1.3*. + +- Extended the *postUdiEvent* endpoint with an optional "roundtrip_efficiency" field, for use in scheduling. + + v1.3-10 | 2021-11-08 """""""""""""""""""" diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4015730cc..b79348562 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -11,6 +11,7 @@ v0.8.0 | November XX, 2021 New features ----------- * Charts with sensor data can be requested in one of the supported [`vega-lite themes `_] (incl. a dark theme) [see `PR #221 `_] +* Schedulers take into account round-trip efficiency if set [see `PR #291 `_] Bugfixes ----------- diff --git a/flexmeasures/api/v1_3/implementations.py b/flexmeasures/api/v1_3/implementations.py index 53fe333cf..d478ff279 100644 --- a/flexmeasures/api/v1_3/implementations.py +++ b/flexmeasures/api/v1_3/implementations.py @@ -280,6 +280,9 @@ def post_udi_event_response(unit): if unit == "kWh": value = value / 1000.0 + # get optional efficiency + roundtrip_efficiency = form.get("roundtrip_efficiency", None) + # set soc targets start_of_schedule = datetime end_of_schedule = datetime + current_app.config.get("FLEXMEASURES_PLANNING_HORIZON") @@ -349,6 +352,7 @@ def post_udi_event_response(unit): belief_time=datetime, soc_at_start=value, soc_targets=soc_targets, + roundtrip_efficiency=roundtrip_efficiency, udi_event_ea=form.get("event"), enqueue=True, ) diff --git a/flexmeasures/api/v1_3/routes.py b/flexmeasures/api/v1_3/routes.py index 0e4c5addd..4154c7693 100644 --- a/flexmeasures/api/v1_3/routes.py +++ b/flexmeasures/api/v1_3/routes.py @@ -104,6 +104,7 @@ def post_udi_event(): This "PostUdiEventRequest" message posts a state of charge (soc) of 12.1 kWh at 10.00am, and a target state of charge of 25 kWh at 4.00pm, as UDI event 204 of device 10 of owner 7. + Roundtrip efficiency for use in scheduling is set to 98%. .. code-block:: json @@ -118,7 +119,8 @@ def post_udi_event(): "value": 25, "datetime": "2015-06-02T16:00:00+00:00" } - ] + ], + "roundtrip_efficiency": 0.98 } **Example response** diff --git a/flexmeasures/data/models/planning/battery.py b/flexmeasures/data/models/planning/battery.py index 98d95ba0c..41798122f 100644 --- a/flexmeasures/data/models/planning/battery.py +++ b/flexmeasures/data/models/planning/battery.py @@ -21,6 +21,7 @@ def schedule_battery( resolution: timedelta, soc_at_start: float, soc_targets: Optional[pd.Series] = None, + roundtrip_efficiency: Optional[float] = None, prefer_charging_sooner: bool = True, ) -> Union[pd.Series, None]: """Schedule a battery asset based directly on the latest beliefs regarding market prices within the specified time @@ -37,6 +38,13 @@ def schedule_battery( ], ) + # Check for round-trip efficiency + if roundtrip_efficiency is None: + # Get default from sensor, or use 100% otherwise + roundtrip_efficiency = sensor.get_attribute("roundtrip_efficiency", 1) + if roundtrip_efficiency <= 0 or roundtrip_efficiency > 1: + raise ValueError("roundtrip_efficiency expected within the interval (0, 1]") + # Check for known prices or price forecasts, trimming planning window accordingly prices, (start, end) = get_prices( sensor, (start, end), resolution, allow_trimmed_query_window=True @@ -69,6 +77,8 @@ def schedule_battery( "derivative equals", "derivative max", "derivative min", + "derivative down efficiency", + "derivative up efficiency", ] device_constraints = [initialize_df(columns, start, end, resolution)] if soc_targets is not None: @@ -90,6 +100,10 @@ def schedule_battery( ) device_constraints[0]["derivative max"] = sensor.get_attribute("capacity_in_mw") + # Apply round-trip efficiency evenly to charging and discharging + 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) columns = ["derivative max", "derivative min"] ems_constraints = initialize_df(columns, start, end, resolution) diff --git a/flexmeasures/data/models/planning/charging_station.py b/flexmeasures/data/models/planning/charging_station.py index 279fd9b71..93de81ac8 100644 --- a/flexmeasures/data/models/planning/charging_station.py +++ b/flexmeasures/data/models/planning/charging_station.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union from datetime import datetime, timedelta from pandas import Series, Timestamp @@ -21,6 +21,7 @@ def schedule_charging_station( resolution: timedelta, soc_at_start: float, soc_targets: Series, + roundtrip_efficiency: Optional[float] = None, prefer_charging_sooner: bool = True, ) -> Union[Series, None]: """Schedule a charging station asset based directly on the latest beliefs regarding market prices within the specified time @@ -32,6 +33,13 @@ def schedule_charging_station( # Check for required Sensor attributes sensor.check_required_attributes([("capacity_in_mw", (float, int))]) + # Check for round-trip efficiency + if roundtrip_efficiency is None: + # Get default from sensor, or use 100% otherwise + roundtrip_efficiency = sensor.get_attribute("roundtrip_efficiency", 1) + if roundtrip_efficiency <= 0 or roundtrip_efficiency > 1: + raise ValueError("roundtrip_efficiency expected within the interval (0, 1]") + # Check for known prices or price forecasts, trimming planning window accordingly prices, (start, end) = get_prices( sensor, (start, end), resolution, allow_trimmed_query_window=True @@ -95,6 +103,10 @@ def schedule_charging_station( else: device_constraints[0]["derivative max"] = sensor.get_attribute("capacity_in_mw") + # Apply round-trip efficiency evenly to charging and discharging + 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) columns = ["derivative max", "derivative min"] ems_constraints = initialize_df(columns, start, end, resolution) diff --git a/flexmeasures/data/models/planning/solver.py b/flexmeasures/data/models/planning/solver.py index 78341b5e8..a473553f3 100644 --- a/flexmeasures/data/models/planning/solver.py +++ b/flexmeasures/data/models/planning/solver.py @@ -10,6 +10,8 @@ RangeSet, Param, Reals, + NonNegativeReals, + NonPositiveReals, Constraint, Objective, minimize, @@ -30,8 +32,11 @@ def device_scheduler( # noqa C901 commitment_downwards_deviation_price: Union[List[pd.Series], List[float]], commitment_upwards_deviation_price: Union[List[pd.Series], List[float]], ) -> Tuple[List[pd.Series], float, SolverResults]: - """Schedule devices given constraints on a device and EMS level, and given a list of commitments by the EMS. - The commitments are assumed to be with regards to the flow of energy to the device (positive for consumption, + """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, + and with multiple market commitments on the EMS level. + A typical example is a house with many devices. + The commitments are assumed to be with regard to the flow of energy to the device (positive for consumption, negative for production). The solver minimises the costs of deviating from the commitments. Device constraints are on a device level. Handled constraints (listed by column name): @@ -41,6 +46,8 @@ def device_scheduler( # noqa C901 derivative max: maximum flow (e.g. in MW or boxes/h) derivative min: minimum flow derivative equals: exact amount of flow (we do this by clamping derivative min and derivative max) + derivative down efficiency: ratio of downwards flows (flow into EMS : flow out of device) + derivative up efficiency: ratio of upwards flows (flow into device : flow out of EMS) EMS constraints are on an EMS level. Handled constraints (listed by column name): derivative max: maximum flow derivative min: minimum flow @@ -54,13 +61,13 @@ def device_scheduler( # noqa C901 All Series and DataFrames should have the same resolution. - For now we pass in the various constraints and prices as separate variables, from which we make a MultiIndex + For now, we pass in the various constraints and prices as separate variables, from which we make a MultiIndex DataFrame. Later we could pass in a MultiIndex DataFrame directly. """ # If the EMS has no devices, don't bother if len(device_constraints) == 0: - return [], 0 + return [], 0, SolverResults() # Check if commitments have the same time window and resolution as the constraints start = device_constraints[0].index.to_pydatetime()[0] @@ -164,6 +171,18 @@ def ems_derivative_min_select(m, j): else: return v + def device_derivative_down_efficiency(m, d, j): + try: + return device_constraints[d]["derivative down efficiency"].iloc[j] + except KeyError: + return 1 + + def device_derivative_up_efficiency(m, d, j): + try: + return device_constraints[d]["derivative up efficiency"].iloc[j] + except KeyError: + return 1 + 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.commitment_quantity = Param( @@ -179,45 +198,107 @@ def ems_derivative_min_select(m, j): ) model.ems_derivative_max = Param(model.j, initialize=ems_derivative_max_select) model.ems_derivative_min = Param(model.j, initialize=ems_derivative_min_select) + model.device_derivative_down_efficiency = Param( + model.d, model.j, initialize=device_derivative_down_efficiency + ) + model.device_derivative_up_efficiency = Param( + model.d, model.j, initialize=device_derivative_up_efficiency + ) # Add variables - model.power = Var(model.d, model.j, domain=Reals, initialize=0) + model.ems_power = Var(model.d, model.j, domain=Reals, initialize=0) + model.device_power_down = Var( + model.d, model.j, domain=NonPositiveReals, initialize=0 + ) + 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.commitment_upwards_deviation = Var( + model.c, model.j, domain=NonNegativeReals, initialize=0 + ) # Add constraints as a tuple of (lower bound, value, upper bound) def device_bounds(m, d, j): return ( m.device_min[d, j], - sum(m.power[d, k] for k in range(0, j + 1)), + sum( + m.device_power_down[d, k] + m.device_power_up[d, k] + for k in range(0, j + 1) + ), m.device_max[d, j], ) def device_derivative_bounds(m, d, j): return ( m.device_derivative_min[d, j], - m.power[d, j], + m.device_power_down[d, j] + m.device_power_up[d, j], + m.device_derivative_max[d, j], + ) + + def device_down_derivative_bounds(m, d, j): + return ( + m.device_derivative_min[d, j], + m.device_power_down[d, j], + 0, + ) + + def device_up_derivative_bounds(m, d, j): + return ( + 0, + m.device_power_up[d, j], m.device_derivative_max[d, j], ) def ems_derivative_bounds(m, j): - return m.ems_derivative_min[j], sum(m.power[:, j]), m.ems_derivative_max[j] + return m.ems_derivative_min[j], sum(m.ems_power[:, j]), m.ems_derivative_max[j] + + def ems_flow_commitment_equalities(m, j): + """Couple EMS flows (sum over devices) to commitments.""" + return ( + 0, + sum(m.commitment_quantity[:, j]) + + sum(m.commitment_downwards_deviation[:, j]) + + sum(m.commitment_upwards_deviation[:, j]) + - sum(m.ems_power[:, j]), + 0, + ) + + def device_derivative_equalities(m, d, j): + """Couple device flows to EMS flows per device, applying efficiencies.""" + return ( + 0, + m.device_power_up[d, j] / m.device_derivative_up_efficiency[d, j] + + m.device_power_down[d, j] * m.device_derivative_down_efficiency[d, j] + - m.ems_power[d, j], + 0, + ) model.device_energy_bounds = Constraint(model.d, model.j, rule=device_bounds) model.device_power_bounds = Constraint( model.d, model.j, rule=device_derivative_bounds ) + model.device_power_down_bounds = Constraint( + model.d, model.j, rule=device_down_derivative_bounds + ) + model.device_power_up_bounds = Constraint( + model.d, model.j, rule=device_up_derivative_bounds + ) model.ems_power_bounds = Constraint(model.j, rule=ems_derivative_bounds) + model.ems_power_commitment_equalities = Constraint( + model.j, rule=ems_flow_commitment_equalities + ) + model.device_power_equalities = Constraint( + model.d, model.j, rule=device_derivative_equalities + ) # Add objective def cost_function(m): costs = 0 for c in m.c: for j in m.j: - ems_power_in_j = sum(m.power[d, j] for d in m.d) - ems_power_deviation = ems_power_in_j - m.commitment_quantity[c, j] - if value(ems_power_deviation) >= 0: - costs += ems_power_deviation * m.up_price[c, j] - else: - costs += ems_power_deviation * m.down_price[c, 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] return costs model.costs = Objective(rule=cost_function, sense=minimize) @@ -230,7 +311,10 @@ def cost_function(m): planned_costs = value(model.costs) planned_power_per_device = [] for d in model.d: - planned_device_power = [model.power[d, j].value for j in model.j] + planned_device_power = [ + model.device_power_down[d, j].value + model.device_power_up[d, j].value + for j in model.j + ] planned_power_per_device.append( pd.Series( index=pd.date_range( @@ -243,5 +327,5 @@ def cost_function(m): # model.pprint() # print(results.solver.termination_condition) # print(planned_costs) - # input() + # model.display() return planned_power_per_device, planned_costs, results diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 10562f14d..a979d743d 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -11,6 +11,9 @@ from flexmeasures.utils.time_utils import as_server_time +TOLERANCE = 0.00001 + + def test_battery_solver_day_1(add_battery_assets): epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() @@ -26,14 +29,34 @@ def test_battery_solver_day_1(add_battery_assets): print(soc_schedule) # Check if constraints were met - assert min(schedule.values) >= battery.get_attribute("capacity_in_mw") * -1 + 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") -def test_battery_solver_day_2(add_battery_assets): +@pytest.mark.parametrize( + "roundtrip_efficiency", + [ + 1, + 0.99, + 0.01, + ], +) +def test_battery_solver_day_2(add_battery_assets, roundtrip_efficiency: float): + """Check battery scheduling results for day 2, which is set up with + 8 expensive, then 8 cheap, then again 8 expensive hours. + If efficiency losses aren't too bad, 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 + If efficiency losses are bad, the price difference is not worth cycling the battery, + and so we expect the scheduler to only: + - completely discharge within the last 8 hours + """ 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 @@ -41,7 +64,14 @@ def test_battery_solver_day_2(add_battery_assets): end = as_server_time(datetime(2015, 1, 3)) 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, + roundtrip_efficiency=roundtrip_efficiency, + ) soc_schedule = integrate_time_series(schedule, soc_at_start, decimal_precision=6) with pd.option_context("display.max_rows", None, "display.max_columns", 3): @@ -49,7 +79,7 @@ def test_battery_solver_day_2(add_battery_assets): # Check if constraints were met assert min(schedule.values) >= battery.get_attribute("capacity_in_mw") * -1 - assert max(schedule.values) <= battery.get_attribute("capacity_in_mw") + assert max(schedule.values) <= battery.get_attribute("capacity_in_mw") + TOLERANCE for soc in soc_schedule.values: assert soc >= battery.get_attribute("min_soc_in_mwh") assert soc <= battery.get_attribute("max_soc_in_mwh") @@ -58,12 +88,23 @@ def test_battery_solver_day_2(add_battery_assets): assert soc_schedule.iloc[-1] == 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)] == battery.get_attribute( - "min_soc_in_mwh" - ) # Sell what you begin with - assert soc_schedule.loc[start + timedelta(hours=16)] == battery.get_attribute( - "max_soc_in_mwh" - ) # Buy what you can to sell later + + # As long as the roundtrip efficiency isn't too bad (I haven't computed the actual switch point) + if roundtrip_efficiency > 0.9: + assert soc_schedule.loc[start + timedelta(hours=8)] == battery.get_attribute( + "min_soc_in_mwh" + ) # Sell what you begin with + assert soc_schedule.loc[start + timedelta(hours=16)] == battery.get_attribute( + "max_soc_in_mwh" + ) # Buy what you can to sell later + else: + # If the roundtrip efficiency is poor, best to stand idle + assert soc_schedule.loc[start + timedelta(hours=8)] == battery.get_attribute( + "soc_in_mwh" + ) + assert soc_schedule.loc[start + timedelta(hours=16)] == battery.get_attribute( + "soc_in_mwh" + ) @pytest.mark.parametrize( @@ -109,12 +150,13 @@ def test_charging_station_solver_day_2(target_soc, charging_station_name): min(consumption_schedule.values) >= charging_station.get_attribute("capacity_in_mw") * -1 ) - assert max(consumption_schedule.values) <= charging_station.get_attribute( - "capacity_in_mw" + assert ( + max(consumption_schedule.values) + <= charging_station.get_attribute("capacity_in_mw") + TOLERANCE ) print(consumption_schedule.head(12)) print(soc_schedule.head(12)) - assert abs(soc_schedule.loc[target_soc_datetime] - target_soc) < 0.00001 + assert abs(soc_schedule.loc[target_soc_datetime] - target_soc) < TOLERANCE @pytest.mark.parametrize( @@ -171,5 +213,5 @@ def test_fallback_to_unsolvable_problem(target_soc, charging_station_name): print(soc_schedule.head(12)) assert ( abs(abs(soc_schedule.loc[target_soc_datetime] - target_soc) - expected_gap) - < 0.00001 + < TOLERANCE ) diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 1ea875994..13af125bd 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -36,6 +36,7 @@ def create_scheduling_job( resolution: timedelta = DEFAULT_RESOLUTION, soc_at_start: Optional[float] = None, soc_targets: Optional[pd.Series] = None, + roundtrip_efficiency: Optional[float] = None, udi_event_ea: Optional[str] = None, enqueue: bool = True, ) -> Job: @@ -61,6 +62,7 @@ def create_scheduling_job( resolution=resolution, soc_at_start=soc_at_start, soc_targets=soc_targets, + roundtrip_efficiency=roundtrip_efficiency, ), id=udi_event_ea, connection=current_app.queues["scheduling"].connection, @@ -88,6 +90,7 @@ def make_schedule( resolution: timedelta, soc_at_start: Optional[float] = None, soc_targets: Optional[pd.Series] = None, + roundtrip_efficiency: Optional[float] = 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). @@ -122,14 +125,26 @@ def make_schedule( if sensor.generic_asset.generic_asset_type.name == "battery": consumption_schedule = schedule_battery( - sensor, start, end, resolution, soc_at_start, soc_targets + sensor, + start, + end, + resolution, + soc_at_start, + soc_targets, + roundtrip_efficiency, ) elif sensor.generic_asset.generic_asset_type.name in ( "one-way_evse", "two-way_evse", ): consumption_schedule = schedule_charging_station( - sensor, start, end, resolution, soc_at_start, soc_targets + sensor, + start, + end, + resolution, + soc_at_start, + soc_targets, + roundtrip_efficiency, ) else: raise ValueError(