From 62a3c21a5a47ff5e94436f4aa62a5ba3fe065f8f Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Wed, 19 Jul 2023 17:32:08 +0200 Subject: [PATCH 01/51] add highs to requirements Signed-off-by: Victor Garcia Reolid --- documentation/configuration.rst | 2 +- requirements/app.in | 1 + requirements/app.txt | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/documentation/configuration.rst b/documentation/configuration.rst index 899a06d98..ab0757b79 100644 --- a/documentation/configuration.rst +++ b/documentation/configuration.rst @@ -55,7 +55,7 @@ Default: ``False`` FLEXMEASURES_LP_SOLVER ^^^^^^^^^^^^^^^^^^^^^^ -The command to run the scheduling solver. This is the executable command which FlexMeasures calls via the `pyomo library `_. Other values might be ``cplex`` or ``glpk``. Consult `their documentation `_ to learn more. +The command to run the scheduling solver. This is the executable command which FlexMeasures calls via the `pyomo library `_. Other values might be ``cplex``, ``glpk`` or ``appsi_highs`` for `Highs `_. Consult `their documentation `_ to learn more. Default: ``"cbc"`` diff --git a/requirements/app.in b/requirements/app.in index 2809f4ec9..20782b148 100644 --- a/requirements/app.in +++ b/requirements/app.in @@ -68,3 +68,4 @@ Flask-SQLAlchemy>=2.4.3,<3 # <2.3: https://github.com/Parallels/rq-dashboard/issues/417 and https://github.com/FlexMeasures/flexmeasures/issues/754 and flask-login 0.6.1 not compatible flask>=1.0, <=2.1.2 werkzeug<=2.1 +highspy \ No newline at end of file diff --git a/requirements/app.txt b/requirements/app.txt index 340a23f4d..139a8247e 100644 --- a/requirements/app.txt +++ b/requirements/app.txt @@ -109,6 +109,8 @@ fonttools==4.40.0 # via matplotlib greenlet==2.0.2 # via sqlalchemy +highspy==1.5.3 + # via -r requirements/app.in humanize==4.7.0 # via -r requirements/app.in idna==3.4 From ebbc65af25d4476ee9cf165c408e9b206143ac03 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Fri, 21 Jul 2023 14:09:53 +0200 Subject: [PATCH 02/51] docs: add changelog entry Signed-off-by: Victor Garcia Reolid --- documentation/changelog.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index eaaf66075..84fb9ce03 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -27,8 +27,7 @@ Infrastructure / Support * Add support for profiling Flask API calls using ``pyinstrument`` (if installed). Can be enabled by setting the environment variable ``FLEXMEASURES_PROFILE_REQUESTS`` to ``True`` [see `PR #722 `_] * The endpoint `[POST] /health/ready `_ returns the status of the Redis connection, if configured [see `PR #699 `_] * Document the `device_scheduler` linear program [see `PR #764 `_]. - -/api/v3_0/health/ready +* Add support for `Highs `_ solver [see `PR #766 `_]. v0.14.1 | June 26, 2023 ============================ From 51a3148fe2474363e934958a9493385ff3bdbfc3 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 00:05:41 +0200 Subject: [PATCH 03/51] fix: get results with infeasible termination status instead of RuntimeError Signed-off-by: Victor Garcia Reolid --- .../data/models/planning/linear_optimization.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index f0d79f866..f7385d455 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -339,9 +339,16 @@ def cost_function(m): model.costs = Objective(rule=cost_function, sense=minimize) # Solve - results = SolverFactory(current_app.config.get("FLEXMEASURES_LP_SOLVER")).solve( - model - ) + solver_name = current_app.config.get("FLEXMEASURES_LP_SOLVER") + opt = SolverFactory(solver_name) + + if "highs" in solver_name.lower(): + try: + results = opt.solve(model) + except RuntimeError: + results = opt.solve(model, load_solutions=False) + else: + results = opt.solve(model) planned_costs = value(model.costs) planned_power_per_device = [] From 708badfbc278fbaf438f9aca11f735b689891492 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 24 Jul 2023 09:31:32 +0200 Subject: [PATCH 04/51] feat: add Binary constraint to prevent energy losses, and start new test against negative prices Signed-off-by: F.N. Claessen --- flexmeasures/conftest.py | 21 +++++- .../models/planning/linear_optimization.py | 18 +++++ .../data/models/planning/tests/test_solver.py | 72 ++++++++++++------- .../data/models/planning/tests/utils.py | 32 +++++++++ flexmeasures/utils/config_defaults.py | 2 +- 5 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 flexmeasures/data/models/planning/tests/utils.py diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 552a74420..58ab17948 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -510,7 +510,7 @@ def create_beliefs(db: SQLAlchemy, setup_markets, setup_sources) -> int: def add_market_prices( db: SQLAlchemy, setup_assets, setup_markets, setup_sources ) -> dict[str, Sensor]: - """Add two days of market prices for the EPEX day-ahead market.""" + """Add three days of market prices for the EPEX day-ahead market.""" # one day of test data (one complete sine curve) time_slots = initialize_index( @@ -551,6 +551,25 @@ def add_market_prices( for dt, val in zip(time_slots, values) ] db.session.add_all(day2_beliefs) + + # the third day of test data (8 hours with negative prices, followed by 16 expensive hours) + time_slots = initialize_index( + start=pd.Timestamp("2015-01-03").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-04").tz_localize("Europe/Amsterdam"), + resolution="1H", + ) + values = [-10] * 8 + [100] * 16 + day3_beliefs = [ + TimedBelief( + event_start=dt, + belief_horizon=timedelta(hours=0), + event_value=val, + source=setup_sources["Seita"], + sensor=setup_markets["epex_da"].corresponding_sensor, + ) + for dt, val in zip(time_slots, values) + ] + db.session.add_all(day3_beliefs) return {"epex_da": setup_markets["epex_da"].corresponding_sensor} diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index f7385d455..5da351e48 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -12,6 +12,7 @@ Reals, NonNegativeReals, NonPositiveReals, + Binary, Constraint, Objective, minimize, @@ -234,6 +235,7 @@ def device_derivative_up_efficiency(m, d, j): model.d, model.j, domain=NonPositiveReals, initialize=0 ) model.device_power_up = Var(model.d, model.j, domain=NonNegativeReals, initialize=0) + model.device_power_sign = Var(model.d, model.j, domain=Binary, initialize=0) model.commitment_downwards_deviation = Var( model.c, model.j, domain=NonPositiveReals, initialize=0 ) @@ -287,6 +289,16 @@ def device_up_derivative_bounds(m, d, j): max(0, m.device_derivative_max[d, j]), ) + def device_up_derivative_sign(m, d, j): + """Derivative up if sign points up, derivative not up if sign points down.""" + # todo determine 1000 from min/max power (we can safely assume these are never None) + return m.device_power_up[d, j] <= 1000 * m.device_power_sign[d, j] + + def device_down_derivative_sign(m, d, j): + """Derivative down if sign points down, derivative not down if sign points up.""" + # todo determine 1000 from min/max power (we can safely assume these are never None) + return -m.device_power_down[d, j] <= 1000 * (1 - m.device_power_sign[d, j]) + def ems_derivative_bounds(m, j): return m.ems_derivative_min[j], sum(m.ems_power[:, j]), m.ems_derivative_max[j] @@ -319,6 +331,12 @@ def device_derivative_equalities(m, d, j): model.device_power_up_bounds = Constraint( model.d, model.j, rule=device_up_derivative_bounds ) + model.device_power_up_sign = Constraint( + model.d, model.j, rule=device_up_derivative_sign + ) + model.device_power_down_sign = Constraint( + model.d, model.j, rule=device_down_derivative_sign + ) model.ems_power_bounds = Constraint(model.j, rule=ems_derivative_bounds) model.ems_power_commitment_equalities = Constraint( model.j, rule=ems_flow_commitment_equalities diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 0f7fba07e..1dc0144a0 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -12,6 +12,7 @@ add_storage_constraints, validate_storage_constraints, ) +from flexmeasures.data.models.planning.tests.utils import check_constraints from flexmeasures.data.models.planning.utils import initialize_series, initialize_df from flexmeasures.utils.calculations import ( apply_stock_changes_and_losses, @@ -80,19 +81,9 @@ def test_battery_solver_day_1( }, ) schedule = scheduler.compute() - 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 - 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") + soc_schedule = check_constraints(battery, schedule, soc_at_start) @pytest.mark.parametrize( @@ -142,24 +133,9 @@ def test_battery_solver_day_2( }, ) schedule = scheduler.compute() - soc_schedule = integrate_time_series( - schedule, - soc_at_start, - up_efficiency=roundtrip_efficiency**0.5, - down_efficiency=roundtrip_efficiency**0.5, - storage_efficiency=storage_efficiency, - decimal_precision=6, - ) - - with pd.option_context("display.max_rows", None, "display.max_columns", 3): - print(soc_schedule) # 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") + TOLERANCE - 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") + soc_schedule = check_constraints(battery, schedule, soc_at_start, roundtrip_efficiency, storage_efficiency) # Check whether the resulting soc schedule follows our expectations for 8 expensive, 8 cheap and 8 expensive hours assert soc_schedule.iloc[-1] == max( @@ -192,6 +168,48 @@ def test_battery_solver_day_2( ) +# @pytest.mark.parametrize("use_inflexible_device", [False, True]) +def test_battery_solver_day_3( + # add_battery_assets, add_inflexible_device_forecasts, use_inflexible_device + add_battery_assets, add_inflexible_device_forecasts, use_inflexible_device = False +): + epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() + battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() + assert battery.get_attribute("market_id") == epex_da.id + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 3)) + end = tz.localize(datetime(2015, 1, 4)) + resolution = timedelta(minutes=15) + soc_at_start = battery.get_attribute("soc_in_mwh") + roundtrip_efficiency = 0.8 + storage_efficiency = 1 + scheduler: Scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model={ + "soc-at-start": soc_at_start, + "roundtrip-efficiency": roundtrip_efficiency, + "storage-efficiency": storage_efficiency, + }, + flex_context={ + "inflexible-device-sensors": [ + s.id for s in add_inflexible_device_forecasts.keys() + ] + if use_inflexible_device + else [] + }, + ) + schedule = scheduler.compute() + + # Check if constraints were met + soc_schedule = check_constraints(battery, schedule, soc_at_start, roundtrip_efficiency, storage_efficiency) + + # Check other assumptions + raise NotImplementedError("todo: check other assumptions") + + @pytest.mark.parametrize( "target_soc, charging_station_name", [ diff --git a/flexmeasures/data/models/planning/tests/utils.py b/flexmeasures/data/models/planning/tests/utils.py new file mode 100644 index 000000000..575c1484e --- /dev/null +++ b/flexmeasures/data/models/planning/tests/utils.py @@ -0,0 +1,32 @@ +import pandas as pd + +from flexmeasures.data.models.time_series import Sensor +from flexmeasures.utils.calculations import integrate_time_series + + +def check_constraints( + sensor: Sensor, + schedule: pd.Series, + soc_at_start: float, + roundtrip_efficiency: float = 1, + storage_efficiency: float = 1, + tolerance: float = 0.00001, +) -> pd.Series: + soc_schedule = integrate_time_series( + schedule, + soc_at_start, + up_efficiency=roundtrip_efficiency**0.5, + down_efficiency=roundtrip_efficiency**0.5, + storage_efficiency=storage_efficiency, + decimal_precision=6, + ) + with pd.option_context("display.max_rows", None, "display.max_columns", 3): + print(soc_schedule) + assert ( + min(schedule.values) >= sensor.get_attribute("capacity_in_mw") * -1 - tolerance + ) + assert max(schedule.values) <= sensor.get_attribute("capacity_in_mw") + tolerance + for soc in soc_schedule.values: + assert soc >= sensor.get_attribute("min_soc_in_mwh") + assert soc <= sensor.get_attribute("max_soc_in_mwh") + return soc_schedule diff --git a/flexmeasures/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py index cd5092cad..1942af4de 100644 --- a/flexmeasures/utils/config_defaults.py +++ b/flexmeasures/utils/config_defaults.py @@ -108,7 +108,7 @@ class Config(object): "renewables": ["solar", "wind"], "EVSE": ["one-way_evse", "two-way_evse"], } # how to group assets by asset types - FLEXMEASURES_LP_SOLVER: str = "cbc" + FLEXMEASURES_LP_SOLVER: str = "appsi_highs" FLEXMEASURES_JOB_TTL: timedelta = timedelta(days=1) FLEXMEASURES_PLANNING_HORIZON: timedelta = timedelta(days=2) FLEXMEASURES_MAX_PLANNING_HORIZON: timedelta | int | None = 2520 # smallest number divisible by 1-10, which yields pleasant-looking durations for common sensor resolutions From a699c16ec7e2f7ea998fb90a990cf36f6d1de584 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 09:53:25 +0200 Subject: [PATCH 05/51] fx: avoid double solving Signed-off-by: Victor Garcia Reolid --- .../models/planning/linear_optimization.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index f7385d455..c40661387 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -18,7 +18,7 @@ ) from pyomo.environ import UnknownSolver # noqa F401 from pyomo.environ import value -from pyomo.opt import SolverFactory, SolverResults +from pyomo.opt import SolverFactory, SolverResults, TerminationCondition from flexmeasures.data.models.planning.utils import initialize_series from flexmeasures.utils.calculations import apply_stock_changes_and_losses @@ -339,16 +339,15 @@ def cost_function(m): model.costs = Objective(rule=cost_function, sense=minimize) # Solve - solver_name = current_app.config.get("FLEXMEASURES_LP_SOLVER") - opt = SolverFactory(solver_name) - if "highs" in solver_name.lower(): - try: - results = opt.solve(model) - except RuntimeError: - results = opt.solve(model, load_solutions=False) - else: - results = opt.solve(model) + # load_solutions=False to avoid a RuntimeError exceptions in HiGHS when solving and infeasible problem. + results = SolverFactory(current_app.config.get("FLEXMEASURES_LP_SOLVER")).solve( + model, load_solutions=False + ) + + # load the results only if the termination conditions is optimal + if results.solver.termination_condition == TerminationCondition.optimal: + model.solutions.load_from(results) planned_costs = value(model.costs) planned_power_per_device = [] From f6b3772089d7aaedd4bc681d952789044c790881 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 09:55:13 +0200 Subject: [PATCH 06/51] style: fix HiGHS capitalization Signed-off-by: Victor Garcia Reolid --- documentation/changelog.rst | 2 +- documentation/configuration.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 84fb9ce03..91e5ad825 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -27,7 +27,7 @@ Infrastructure / Support * Add support for profiling Flask API calls using ``pyinstrument`` (if installed). Can be enabled by setting the environment variable ``FLEXMEASURES_PROFILE_REQUESTS`` to ``True`` [see `PR #722 `_] * The endpoint `[POST] /health/ready `_ returns the status of the Redis connection, if configured [see `PR #699 `_] * Document the `device_scheduler` linear program [see `PR #764 `_]. -* Add support for `Highs `_ solver [see `PR #766 `_]. +* Add support for `HiGHS `_ solver [see `PR #766 `_]. v0.14.1 | June 26, 2023 ============================ diff --git a/documentation/configuration.rst b/documentation/configuration.rst index ab0757b79..635737c82 100644 --- a/documentation/configuration.rst +++ b/documentation/configuration.rst @@ -55,7 +55,7 @@ Default: ``False`` FLEXMEASURES_LP_SOLVER ^^^^^^^^^^^^^^^^^^^^^^ -The command to run the scheduling solver. This is the executable command which FlexMeasures calls via the `pyomo library `_. Other values might be ``cplex``, ``glpk`` or ``appsi_highs`` for `Highs `_. Consult `their documentation `_ to learn more. +The command to run the scheduling solver. This is the executable command which FlexMeasures calls via the `pyomo library `_. Other values might be ``cplex``, ``glpk`` or ``appsi_highs`` for `HiGHS `_. Consult `their documentation `_ to learn more. Default: ``"cbc"`` From 6d9c2a644307ee45db4ff5b58c6a76d5055bdf0f Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 09:57:21 +0200 Subject: [PATCH 07/51] remove HiGHS from requirements Signed-off-by: Victor Garcia Reolid --- requirements/app.in | 3 +-- requirements/app.txt | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/requirements/app.in b/requirements/app.in index 20782b148..936d0c39f 100644 --- a/requirements/app.in +++ b/requirements/app.in @@ -67,5 +67,4 @@ Flask-SQLAlchemy>=2.4.3,<3 # flask should be after all the flask plugins, because setup might find they ARE flask # <2.3: https://github.com/Parallels/rq-dashboard/issues/417 and https://github.com/FlexMeasures/flexmeasures/issues/754 and flask-login 0.6.1 not compatible flask>=1.0, <=2.1.2 -werkzeug<=2.1 -highspy \ No newline at end of file +werkzeug<=2.1 \ No newline at end of file diff --git a/requirements/app.txt b/requirements/app.txt index 139a8247e..340a23f4d 100644 --- a/requirements/app.txt +++ b/requirements/app.txt @@ -109,8 +109,6 @@ fonttools==4.40.0 # via matplotlib greenlet==2.0.2 # via sqlalchemy -highspy==1.5.3 - # via -r requirements/app.in humanize==4.7.0 # via -r requirements/app.in idna==3.4 From 58b24e330a61ec80b7a624ee44b8474c3e95ad37 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 11:48:01 +0200 Subject: [PATCH 08/51] remove dependency Signed-off-by: Victor Garcia Reolid --- requirements/app.in | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements/app.in b/requirements/app.in index 936d0c39f..9f2e5cb85 100644 --- a/requirements/app.in +++ b/requirements/app.in @@ -66,5 +66,4 @@ uniplot>=0.7.0 Flask-SQLAlchemy>=2.4.3,<3 # flask should be after all the flask plugins, because setup might find they ARE flask # <2.3: https://github.com/Parallels/rq-dashboard/issues/417 and https://github.com/FlexMeasures/flexmeasures/issues/754 and flask-login 0.6.1 not compatible -flask>=1.0, <=2.1.2 -werkzeug<=2.1 \ No newline at end of file +flask>=1.0, <=2.1.2 \ No newline at end of file From bb00d3a273f9a432d2ce4cab60ddae9b2703a595 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 11:49:36 +0200 Subject: [PATCH 09/51] add dependency back Signed-off-by: Victor Garcia Reolid --- requirements/app.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements/app.in b/requirements/app.in index 9f2e5cb85..936d0c39f 100644 --- a/requirements/app.in +++ b/requirements/app.in @@ -66,4 +66,5 @@ uniplot>=0.7.0 Flask-SQLAlchemy>=2.4.3,<3 # flask should be after all the flask plugins, because setup might find they ARE flask # <2.3: https://github.com/Parallels/rq-dashboard/issues/417 and https://github.com/FlexMeasures/flexmeasures/issues/754 and flask-login 0.6.1 not compatible -flask>=1.0, <=2.1.2 \ No newline at end of file +flask>=1.0, <=2.1.2 +werkzeug<=2.1 \ No newline at end of file From d2809ad0b8beaee8c8def6a1ec0314b47bb826cd Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 13:28:49 +0200 Subject: [PATCH 10/51] docs: document how to install HiGHS Signed-off-by: Victor Garcia Reolid --- documentation/dev/introduction.rst | 5 +++++ documentation/host/deployment.rst | 9 ++++++++- documentation/tut/installation.rst | 16 +++++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/documentation/dev/introduction.rst b/documentation/dev/introduction.rst index f055f72b3..2c9623d35 100644 --- a/documentation/dev/introduction.rst +++ b/documentation/dev/introduction.rst @@ -54,6 +54,11 @@ Go into the ``flexmeasures`` folder and install all dependencies including the o $ apt-get install coinor-cbc +Alternatively, HiGHS solver can be installed with pip: + +.. code-block:: bash + + $ pip install highspy Configuration ^^^^^^^^^^^^^^^^^^^^ diff --git a/documentation/host/deployment.rst b/documentation/host/deployment.rst index de1972a81..449ad0d25 100644 --- a/documentation/host/deployment.rst +++ b/documentation/host/deployment.rst @@ -48,7 +48,7 @@ Keep in mind that FlexMeasures is based on `Flask `_ mixed integer linear optimization solver. +To compute schedules, FlexMeasures uses the `Cbc `_ or `HiGHS `_ mixed integer linear optimization solvers. It is used through `Pyomo `_\ , so in principle supporting a `different solver `_ would be possible. Cbc needs to be present on the server where FlexMeasures runs, under the ``cbc`` command. @@ -66,3 +66,10 @@ pass a directory for the installation. In case you want to install a later version, adapt the version in the script. +HiGHS can be installed using pip: + +.. code-block:: bash + + $ pip install highspy + + diff --git a/documentation/tut/installation.rst b/documentation/tut/installation.rst index ab3ef1c35..4878e4a87 100644 --- a/documentation/tut/installation.rst +++ b/documentation/tut/installation.rst @@ -226,7 +226,10 @@ For FlexMeasures to be able to send email to users (e.g. for resetting passwords Install an LP solver ^^^^^^^^^^^^^^^^^^^^ -For planning balancing actions, the FlexMeasures platform uses a linear program solver. Currently that is the Cbc solver. See :ref:`solver-config` if you want to change to a different solver. +For planning balancing actions, the FlexMeasures platform uses a linear program solver. Currently that is the Cbc or HiGHS solvers. See :ref:`solver-config` if you want to change to a different solver. + +CBC +***** Installing Cbc can be done on Unix via: @@ -241,6 +244,17 @@ We provide a script for installing from source (without requiring ``sudo`` right More information (e.g. for installing on Windows) on `the Cbc website `_. +HiGHS +****** + +HiGHS is a modern LP solver that aims at solving large problems. It can installed using pip: + +.. code-block:: bash + + $ pip install highspy + +More information (e.g. for installing on Windows) on `the HiGHS website `_. + Install and configure Redis ^^^^^^^^^^^^^^^^^^^^^^^ From 2a94530abb9b4c7951fa5fd21bb2b0ba8ed15ac6 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 13:30:51 +0200 Subject: [PATCH 11/51] add HIghs to Dockerfile Signed-off-by: Victor Garcia Reolid --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 403b5dcb4..efb13e60b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,10 @@ WORKDIR /app # requirements - doing this earlier, so we don't install them each time. Use --no-cache to refresh them. COPY requirements /app/requirements + + # py dev tooling -RUN python3 -m pip install --no-cache-dir --upgrade pip && python3 --version && pip3 install --no-cache-dir --upgrade setuptools && pip3 install --no-cache-dir -r requirements/app.txt -r requirements/dev.txt -r requirements/test.txt +RUN python3 -m pip install --no-cache-dir --upgrade pip && python3 --version && pip3 install --no-cache-dir --upgrade setuptools && pip3 install highspy && pip3 install --no-cache-dir -r requirements/app.txt -r requirements/dev.txt -r requirements/test.txt # Copy code and meta/config data COPY setup.* .flaskenv wsgi.py /app/ From ffacf3046aadd245ce8094e9179f926c036b55e5 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 13:31:11 +0200 Subject: [PATCH 12/51] remove extra lines Signed-off-by: Victor Garcia Reolid --- Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index efb13e60b..b3a86d787 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,6 @@ WORKDIR /app # requirements - doing this earlier, so we don't install them each time. Use --no-cache to refresh them. COPY requirements /app/requirements - - # py dev tooling RUN python3 -m pip install --no-cache-dir --upgrade pip && python3 --version && pip3 install --no-cache-dir --upgrade setuptools && pip3 install highspy && pip3 install --no-cache-dir -r requirements/app.txt -r requirements/dev.txt -r requirements/test.txt From ce969db929151e9edf6c79a9423777a7c476d27b Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 13:34:03 +0200 Subject: [PATCH 13/51] fix typos Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/linear_optimization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index c40661387..b617ac340 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -340,7 +340,7 @@ def cost_function(m): # Solve - # load_solutions=False to avoid a RuntimeError exceptions in HiGHS when solving and infeasible problem. + # load_solutions=False to avoid a RuntimeError exception in appsi solvers when solving an infeasible problem. results = SolverFactory(current_app.config.get("FLEXMEASURES_LP_SOLVER")).solve( model, load_solutions=False ) From ae09379dcb486ab5ee1522517a5ff1a177ad39a7 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 13:35:41 +0200 Subject: [PATCH 14/51] load solution when termination_condition!=infeasible Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/linear_optimization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index b617ac340..c7c7050a8 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -345,8 +345,8 @@ def cost_function(m): model, load_solutions=False ) - # load the results only if the termination conditions is optimal - if results.solver.termination_condition == TerminationCondition.optimal: + # load the results only if the termination conditions is not infeasible + if results.solver.termination_condition != TerminationCondition.infeasible: model.solutions.load_from(results) planned_costs = value(model.costs) From e9d846220e916061eeb33705690e3f36799d0ad1 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 20:54:34 +0200 Subject: [PATCH 15/51] refactor: run data preparation step in a different method Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/storage.py | 39 +++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index a46f5a88b..d5fc58507 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -50,13 +50,14 @@ def compute_schedule(self) -> pd.Series | None: return self.compute() - def compute(self, skip_validation: bool = False) -> pd.Series | None: + def _prepare(self, skip_validation: bool = False) -> pd.Series | None: """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. For the resulting consumption schedule, consumption is defined as positive values. :param skip_validation: If True, skip validation of constraints specified in the data. :returns: The computed schedule. """ + if not self.config_deserialized: self.deserialize_config() @@ -203,7 +204,41 @@ def compute(self, skip_validation: bool = False) -> pd.Series | None: ems_constraints["derivative min"] = ems_capacity * -1 ems_constraints["derivative max"] = ems_capacity - ems_schedule, expected_costs, scheduler_results = device_scheduler( + return ( + sensor, + start, + end, + resolution, + soc_at_start, + device_constraints, + ems_constraints, + commitment_quantities, + commitment_downwards_deviation_price, + commitment_upwards_deviation_price, + ) + + def compute(self, skip_validation: bool = False) -> pd.Series | None: + """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. + For the resulting consumption schedule, consumption is defined as positive values. + + :param skip_validation: If True, skip validation of constraints specified in the data. + :returns: The computed schedule. + """ + + ( + sensor, + start, + end, + resolution, + soc_at_start, + device_constraints, + ems_constraints, + commitment_quantities, + commitment_downwards_deviation_price, + commitment_upwards_deviation_price, + ) = self._prepare(skip_validation=skip_validation) + + ems_schedule, expected_costs, scheduler_results, _ = device_scheduler( device_constraints, ems_constraints, commitment_quantities, From aa328391dd4d0eb1ce7cbe71c0e37628d7b35c60 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 20:56:46 +0200 Subject: [PATCH 16/51] refactor: create run_device_scheduler function to return model and results objects and using it in the device_scheduler function Signed-off-by: Victor Garcia Reolid --- .../models/planning/linear_optimization.py | 82 +++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 5da351e48..ecf91243f 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -27,15 +27,17 @@ infinity = float("inf") -def device_scheduler( # noqa C901 +def run_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]], initial_stock: float = 0, -) -> Tuple[List[pd.Series], float, SolverResults]: - """This generic device scheduler is able to handle an EMS with multiple devices, +) -> Tuple[ConcreteModel, SolverResults]: + """This function solves the device scheduler model, returning the solution and the result metadata. + + 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. @@ -93,6 +95,15 @@ def device_scheduler( # noqa C901 % (resolution, resolution_c) ) + # Compute a good value for M + M = 0.1 + for device_constraint in device_constraints: + M = max( + M, + device_constraint["derivative max"].max(), + -device_constraint["derivative min"].min(), + ) + # Turn prices per commitment into prices per commitment flow if len(commitment_downwards_deviation_price) != 0: if all( @@ -291,13 +302,11 @@ def device_up_derivative_bounds(m, d, j): def device_up_derivative_sign(m, d, j): """Derivative up if sign points up, derivative not up if sign points down.""" - # todo determine 1000 from min/max power (we can safely assume these are never None) - return m.device_power_up[d, j] <= 1000 * m.device_power_sign[d, j] + return m.device_power_up[d, j] <= M * m.device_power_sign[d, j] def device_down_derivative_sign(m, d, j): """Derivative down if sign points down, derivative not down if sign points up.""" - # todo determine 1000 from min/max power (we can safely assume these are never None) - return -m.device_power_down[d, j] <= 1000 * (1 - m.device_power_sign[d, j]) + return -m.device_power_down[d, j] <= M * (1 - m.device_power_sign[d, j]) def ems_derivative_bounds(m, j): return m.ems_derivative_min[j], sum(m.ems_power[:, j]), m.ems_derivative_max[j] @@ -368,6 +377,65 @@ def cost_function(m): else: results = opt.solve(model) + return model, results + + +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]], + initial_stock: float = 0, +) -> 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, + 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): + max: maximum stock assuming an initial stock of zero (e.g. in MWh or boxes) + min: minimum stock assuming an initial stock of zero + equal: exact amount of stock (we do this by clamping min and max) + efficiency: amount of stock left at the next datetime (the rest is lost) + 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: conversion efficiency of flow out of a device (flow out : stock decrease) + derivative up efficiency: conversion efficiency of flow into a device (stock increase : flow in) + EMS constraints are on an EMS level. Handled constraints (listed by column name): + derivative max: maximum flow + derivative min: minimum flow + 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 + - 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 + + 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 + DataFrame. Later we could pass in a MultiIndex DataFrame directly. + """ + + start = device_constraints[0].index.to_pydatetime()[0] + # Workaround for https://github.com/pandas-dev/pandas/issues/53643. Was: resolution = pd.to_timedelta(device_constraints[0].index.freq) + resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta() + end = device_constraints[0].index.to_pydatetime()[-1] + resolution + + model, results = run_device_scheduler_model( # noqa C901 + device_constraints, + ems_constraints, + commitment_quantities, + commitment_downwards_deviation_price, + commitment_upwards_deviation_price, + initial_stock, + ) + planned_costs = value(model.costs) planned_power_per_device = [] for d in model.d: From 2a159fbc5a7b140d967250f449abc60b6bf1c173 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 20:59:51 +0200 Subject: [PATCH 17/51] add asserts and docstring in test_battery_solver_day_3 Signed-off-by: Victor Garcia Reolid --- .../data/models/planning/tests/test_solver.py | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 1dc0144a0..94e462f8a 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -12,6 +12,7 @@ add_storage_constraints, validate_storage_constraints, ) +from flexmeasures.data.models.planning.linear_optimization import run_device_scheduler from flexmeasures.data.models.planning.tests.utils import check_constraints from flexmeasures.data.models.planning.utils import initialize_series, initialize_df from flexmeasures.utils.calculations import ( @@ -83,7 +84,7 @@ def test_battery_solver_day_1( schedule = scheduler.compute() # Check if constraints were met - soc_schedule = check_constraints(battery, schedule, soc_at_start) + check_constraints(battery, schedule, soc_at_start) @pytest.mark.parametrize( @@ -135,7 +136,9 @@ def test_battery_solver_day_2( schedule = scheduler.compute() # Check if constraints were met - soc_schedule = check_constraints(battery, schedule, soc_at_start, roundtrip_efficiency, storage_efficiency) + soc_schedule = check_constraints( + battery, schedule, soc_at_start, roundtrip_efficiency, storage_efficiency + ) # Check whether the resulting soc schedule follows our expectations for 8 expensive, 8 cheap and 8 expensive hours assert soc_schedule.iloc[-1] == max( @@ -168,11 +171,22 @@ def test_battery_solver_day_2( ) -# @pytest.mark.parametrize("use_inflexible_device", [False, True]) +@pytest.mark.parametrize("use_inflexible_device", [False, True]) def test_battery_solver_day_3( # add_battery_assets, add_inflexible_device_forecasts, use_inflexible_device - add_battery_assets, add_inflexible_device_forecasts, use_inflexible_device = False + add_battery_assets, + add_inflexible_device_forecasts, + use_inflexible_device=False, ): + """Check battery scheduling results for day 3, which is set up with + 8 hours with negative prices, followed by 16 expensive hours. + + The battery is expected not to exploit the mechanism of charging and discharging within a time period + taking advantage of the inverter losses (heat) to consume energy. Nevertheless, as the consumption and production + prices are equal, the battery follows an oscillating dynamic in periods with negative prices. Again, due to conversion + efficiencies, the battery will charge **less** and will be able to discharge **more** than what is actually stored. + + """ epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() assert battery.get_attribute("market_id") == epex_da.id @@ -201,13 +215,44 @@ def test_battery_solver_day_3( else [] }, ) - schedule = scheduler.compute() - # Check if constraints were met - soc_schedule = check_constraints(battery, schedule, soc_at_start, roundtrip_efficiency, storage_efficiency) + ( + 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) + + model, results = run_device_scheduler( + device_constraints, + ems_constraints, + commitment_quantities, + commitment_downwards_deviation_price, + commitment_upwards_deviation_price, + initial_stock=soc_at_start * (timedelta(hours=1) / resolution), + ) + + device_power_sign = pd.Series(model.device_power_sign.extract_values())[0] + device_power_up = pd.Series(model.device_power_up.extract_values())[0] + device_power_down = pd.Series(model.device_power_down.extract_values())[0] + + is_power_down = ~np.isclose(abs(device_power_down), 0) + is_power_up = ~np.isclose(abs(device_power_up), 0) + + # only one power active at a time + assert (~(is_power_down & is_power_up)).all() + + # downwards power not active when the binary variable is 1 + assert (~is_power_down[device_power_sign == 1.0]).all() - # Check other assumptions - raise NotImplementedError("todo: check other assumptions") + # upwards power not active when the binary variable is 0 + assert (~is_power_up[device_power_sign == 0.0]).all() @pytest.mark.parametrize( From 0d24be69e03961d5620a7dc1bd29fbab3e08d1d6 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 21:00:21 +0200 Subject: [PATCH 18/51] add documentation for the constraints device_up_derivative_sign and device_down_derivative_sign Signed-off-by: Victor Garcia Reolid --- documentation/concepts/device_scheduler.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/documentation/concepts/device_scheduler.rst b/documentation/concepts/device_scheduler.rst index 60b9b90e6..a4ce62dfe 100644 --- a/documentation/concepts/device_scheduler.rst +++ b/documentation/concepts/device_scheduler.rst @@ -48,6 +48,7 @@ Symbol Variable in the Code :math:`P^{ems}_{min}(j)` ems_derivative_min Minimum flow of the EMS during time period :math:`j`. :math:`P^{ems}_{max}(j)` ems_derivative_max Maximum flow of the EMS during time period :math:`j`. :math:`Commitment(c,j)` commitment_quantity Commitment c (at EMS level) over time step :math:`j`. +:math:`M` M Large constant number, upper bound of :math:`Price_{up}(c,j)` and :math:`|Price_{down}(c,j)|` ================================ ================================================ ============================================================================================================== @@ -62,6 +63,7 @@ Symbol Variable in the Code :math:`P_{up}(d,j)` device_power_up Upwards power of device :math:`d` during time period :math:`j`. :math:`P_{down}(d,j)` device_power_down Downwards power of device :math:`d` during time period :math:`j`. :math:`P^{ems}(j)` ems_power Aggregated power of all the devices during time period :math:`j`. +:math:`\sigma(d,j)` device_power_sign Upwards power activation if :math:`\sigma(d,j)=1`, downwards power activation otherwise. ================================ ================================================ ============================================================================================================== Cost function @@ -154,6 +156,22 @@ Device bounds 0 \leq P_{up}(d,j)\leq max(P_{max}(d,j),0) +Upwards/Downwards activation selection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Avoid simultaneous upwards and downwards activation during the same time period. + +.. math:: + :name: device_up_derivative_sign + + P_{up}(d,j) \leq M \cdot \sigma(d,j) + +.. math:: + :name: device_down_derivative_sign + + -P_{down}(d,j) \leq M \cdot (1-\sigma(d,j)) + + Grid constraints ^^^^^^^^^^^^^^^^^ From 55a8a4d706aadfcf1bc86fcc8c4af2d408c4878d Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 21:01:12 +0200 Subject: [PATCH 19/51] install HiGHS in the CI testing env Signed-off-by: Victor Garcia Reolid --- .github/workflows/lint-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index f8d561ee2..6e594a972 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -49,7 +49,7 @@ jobs: ${{ runner.os }}-pip- - run: | ci/setup-postgres.sh - sudo apt-get -y install coinor-cbc + pip install highspy - name: Install FlexMeasures & exact dependencies for tests run: make install-for-test if: github.event_name == 'push' && steps.cache.outputs.cache-hit != 'true' From 3d249ce3018c19b143fa3fe7cafe0bff4f915a0c Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 21:06:05 +0200 Subject: [PATCH 20/51] address some textual changes Signed-off-by: Victor Garcia Reolid --- documentation/host/deployment.rst | 4 ++-- documentation/tut/installation.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/host/deployment.rst b/documentation/host/deployment.rst index 449ad0d25..8725f0b76 100644 --- a/documentation/host/deployment.rst +++ b/documentation/host/deployment.rst @@ -48,8 +48,8 @@ Keep in mind that FlexMeasures is based on `Flask `_ or `HiGHS `_ mixed integer linear optimization solvers. -It is used through `Pyomo `_\ , so in principle supporting a `different solver `_ would be possible. +To compute schedules, FlexMeasures uses the `Cbc `_ (FlexMeasures solver by default) or `HiGHS `_ mixed integer linear optimization solver. +Solvers are used through `Pyomo `_\ , so in principle supporting a `different solver `_ would be possible. Cbc needs to be present on the server where FlexMeasures runs, under the ``cbc`` command. diff --git a/documentation/tut/installation.rst b/documentation/tut/installation.rst index 4878e4a87..51547bcd2 100644 --- a/documentation/tut/installation.rst +++ b/documentation/tut/installation.rst @@ -247,7 +247,7 @@ More information (e.g. for installing on Windows) on `the Cbc website Date: Mon, 24 Jul 2023 21:08:44 +0200 Subject: [PATCH 21/51] fx CBC capitalization Signed-off-by: Victor Garcia Reolid --- documentation/host/deployment.rst | 4 ++-- documentation/tut/installation.rst | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/documentation/host/deployment.rst b/documentation/host/deployment.rst index 8725f0b76..41f335498 100644 --- a/documentation/host/deployment.rst +++ b/documentation/host/deployment.rst @@ -48,10 +48,10 @@ Keep in mind that FlexMeasures is based on `Flask `_ (FlexMeasures solver by default) or `HiGHS `_ mixed integer linear optimization solver. +To compute schedules, FlexMeasures uses the `CBC `_ (FlexMeasures solver by default) or `HiGHS `_ mixed integer linear optimization solver. Solvers are used through `Pyomo `_\ , so in principle supporting a `different solver `_ would be possible. -Cbc needs to be present on the server where FlexMeasures runs, under the ``cbc`` command. +CBC needs to be present on the server where FlexMeasures runs, under the ``cbc`` command. You can install it on Debian like this: diff --git a/documentation/tut/installation.rst b/documentation/tut/installation.rst index 51547bcd2..249e5e456 100644 --- a/documentation/tut/installation.rst +++ b/documentation/tut/installation.rst @@ -226,12 +226,12 @@ For FlexMeasures to be able to send email to users (e.g. for resetting passwords Install an LP solver ^^^^^^^^^^^^^^^^^^^^ -For planning balancing actions, the FlexMeasures platform uses a linear program solver. Currently that is the Cbc or HiGHS solvers. See :ref:`solver-config` if you want to change to a different solver. +For planning balancing actions, the FlexMeasures platform uses a linear program solver. Currently that is the CBC or HiGHS solvers. See :ref:`solver-config` if you want to change to a different solver. CBC ***** -Installing Cbc can be done on Unix via: +Installing CBC can be done on Unix via: .. code-block:: bash @@ -242,7 +242,7 @@ Installing Cbc can be done on Unix via: We provide a script for installing from source (without requiring ``sudo`` rights) in the `ci` folder. -More information (e.g. for installing on Windows) on `the Cbc website `_. +More information (e.g. for installing on Windows) on `the CBC website `_. HiGHS ****** From 9a253dfcaee0efa6b60173f5e546fc30efa7e6e1 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 21:09:07 +0200 Subject: [PATCH 22/51] fix grammar Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/linear_optimization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index c7c7050a8..35be474c1 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -345,7 +345,7 @@ def cost_function(m): model, load_solutions=False ) - # load the results only if the termination conditions is not infeasible + # load the results only if the termination condition is not infeasible if results.solver.termination_condition != TerminationCondition.infeasible: model.solutions.load_from(results) From ca7a0e6f28bcc89f8f05f1ba47099ad64d82c9bf Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 21:10:50 +0200 Subject: [PATCH 23/51] check if there are results in a more robustly Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/linear_optimization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 35be474c1..a28e08fa6 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -18,7 +18,7 @@ ) from pyomo.environ import UnknownSolver # noqa F401 from pyomo.environ import value -from pyomo.opt import SolverFactory, SolverResults, TerminationCondition +from pyomo.opt import SolverFactory, SolverResults from flexmeasures.data.models.planning.utils import initialize_series from flexmeasures.utils.calculations import apply_stock_changes_and_losses @@ -346,7 +346,7 @@ def cost_function(m): ) # load the results only if the termination condition is not infeasible - if results.solver.termination_condition != TerminationCondition.infeasible: + if len(results.solution) > 0: model.solutions.load_from(results) planned_costs = value(model.costs) From aff291ebb9fb205e7ddf100dc540c004dad24903 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 24 Jul 2023 21:19:25 +0200 Subject: [PATCH 24/51] little fixes Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/linear_optimization.py | 2 +- flexmeasures/data/models/planning/storage.py | 2 +- flexmeasures/data/models/planning/tests/test_solver.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 034699800..971de7caa 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -426,7 +426,7 @@ def device_scheduler( # noqa C901 resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta() end = device_constraints[0].index.to_pydatetime()[-1] + resolution - model, results = run_device_scheduler_model( # noqa C901 + model, results = run_device_scheduler( # noqa C901 device_constraints, ems_constraints, commitment_quantities, diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 2f10f8de5..5a0fef166 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -236,7 +236,7 @@ def compute(self, skip_validation: bool = False) -> pd.Series | None: commitment_upwards_deviation_price, ) = self._prepare(skip_validation=skip_validation) - ems_schedule, expected_costs, scheduler_results, _ = device_scheduler( + ems_schedule, expected_costs, scheduler_results = device_scheduler( device_constraints, ems_constraints, commitment_quantities, diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 8dbb1b496..c0a2d74c8 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -172,7 +172,7 @@ def test_battery_solver_day_3( # add_battery_assets, add_inflexible_device_forecasts, use_inflexible_device add_battery_assets, add_inflexible_device_forecasts, - use_inflexible_device=False, + use_inflexible_device, ): """Check battery scheduling results for day 3, which is set up with 8 hours with negative prices, followed by 16 expensive hours. From ce7eafcc4933edee676b330ec38e8c7e5da5191e Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Tue, 25 Jul 2023 09:35:51 +0200 Subject: [PATCH 25/51] improve docstring of _prepare Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/storage.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 5a0fef166..722d072fc 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -50,12 +50,14 @@ def compute_schedule(self) -> pd.Series | None: return self.compute() - def _prepare(self, skip_validation: bool = False) -> pd.Series | None: - """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. - For the resulting consumption schedule, consumption is defined as positive values. + def _prepare(self, skip_validation: bool = False) -> tuple: + """This function prepares the required data to compute the schedule: + - price data + - device constraint + - ems constraints :param skip_validation: If True, skip validation of constraints specified in the data. - :returns: The computed schedule. + :returns: Input data for the scheduler """ if not self.config_deserialized: From d43fbfabd9d90cb6b301e17d481c6b6cdb6accb1 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Fri, 28 Jul 2023 09:40:58 +0200 Subject: [PATCH 26/51] fix definition of M Signed-off-by: Victor Garcia Reolid --- documentation/concepts/device_scheduler.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/concepts/device_scheduler.rst b/documentation/concepts/device_scheduler.rst index a4ce62dfe..e49ceaf85 100644 --- a/documentation/concepts/device_scheduler.rst +++ b/documentation/concepts/device_scheduler.rst @@ -48,7 +48,7 @@ Symbol Variable in the Code :math:`P^{ems}_{min}(j)` ems_derivative_min Minimum flow of the EMS during time period :math:`j`. :math:`P^{ems}_{max}(j)` ems_derivative_max Maximum flow of the EMS during time period :math:`j`. :math:`Commitment(c,j)` commitment_quantity Commitment c (at EMS level) over time step :math:`j`. -:math:`M` M Large constant number, upper bound of :math:`Price_{up}(c,j)` and :math:`|Price_{down}(c,j)|` +:math:`M` M Large constant number, upper bound of :math:`Power_{up}(d,j)` and :math:`|Power_{down}(d,j)|` ================================ ================================================ ============================================================================================================== From 244fd4ed93b7edd9624a2e14bd75bf21b530a3e1 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 31 Jul 2023 09:20:52 +0200 Subject: [PATCH 27/51] improve test Signed-off-by: Victor Garcia Reolid --- .../data/models/planning/tests/test_solver.py | 131 ++++++++++++++---- 1 file changed, 105 insertions(+), 26 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index c0a2d74c8..05c99fba8 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -4,6 +4,7 @@ import numpy as np import pandas as pd +from pandas.tseries.frequencies import to_offset from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.models.planning import Scheduler @@ -167,48 +168,33 @@ def test_battery_solver_day_2( ) -@pytest.mark.parametrize("use_inflexible_device", [False, True]) -def test_battery_solver_day_3( - # add_battery_assets, add_inflexible_device_forecasts, use_inflexible_device - add_battery_assets, - add_inflexible_device_forecasts, - use_inflexible_device, +def run_test_charge_discharge_sign( + roundtrip_efficiency, consumption_price_sensor_id, production_price_sensor_id ): - """Check battery scheduling results for day 3, which is set up with - 8 hours with negative prices, followed by 16 expensive hours. - - The battery is expected not to exploit the mechanism of charging and discharging within a time period - taking advantage of the inverter losses (heat) to consume energy. Nevertheless, as the consumption and production - prices are equal, the battery follows an oscillating dynamic in periods with negative prices. Again, due to conversion - efficiencies, the battery will charge **less** and will be able to discharge **more** than what is actually stored. - - """ - epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() - assert battery.get_attribute("market_id") == epex_da.id + tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 3)) end = tz.localize(datetime(2015, 1, 4)) - resolution = timedelta(minutes=15) - soc_at_start = battery.get_attribute("soc_in_mwh") - roundtrip_efficiency = 0.8 + resolution = timedelta(hours=1) storage_efficiency = 1 + scheduler: Scheduler = StorageScheduler( battery, start, end, resolution, flex_model={ - "soc-at-start": soc_at_start, + "soc-at-start": battery.get_attribute("capacity_in_mw"), + "soc-min": 0, + "soc-max": battery.get_attribute("capacity_in_mw"), "roundtrip-efficiency": roundtrip_efficiency, "storage-efficiency": storage_efficiency, + "prefer-charging-sooner": False, }, flex_context={ - "inflexible-device-sensors": [ - s.id for s in add_inflexible_device_forecasts.keys() - ] - if use_inflexible_device - else [] + "consumption-price-sensor": consumption_price_sensor_id, + "production-price-sensor": production_price_sensor_id, }, ) @@ -250,6 +236,99 @@ def test_battery_solver_day_3( # upwards power not active when the binary variable is 0 assert (~is_power_up[device_power_sign == 0.0]).all() + schedule = initialize_series( + data=[model.ems_power[0, j].value for j in model.j], + start=start, + end=end, + resolution=to_offset(resolution), + ) + + # Check if constraints were met + soc_schedule = check_constraints( + battery, schedule, soc_at_start, roundtrip_efficiency, storage_efficiency + ) + + return schedule.tz_convert(tz), soc_schedule.tz_convert(tz) + + +# todo: think if it is necessary to test with inflexible_device +def test_battery_solver_day_3( + add_battery_assets, + add_inflexible_device_forecasts, +): + """Check battery scheduling results for day 3, which is set up with + 8 hours with negative prices, followed by 16 expensive hours. + + Under certain conditions, batteries can be used to "burn" energy in form of heat, due to the conversion + losses of the inverters. Nonetheless, this doesn't come for free as this is shortening the lifetime of the asset. + For this reason, the constraints `device_up_derivative_sign` and `device_down_derivative_sign' make sure that + the storage can only charge or discharge within the same time period. + + These constraints don't avoid burning energy in Case 1) in which a storage with conversion losses operating under the + same buy/sell prices. + + Nonetheless, as shown in Cases 3) and 4), the oscillatory dynamic is gone when having Consumption Price > Production Price. + This is because even though the energy consumed is bigger than the produced, the difference between the cost of consuming and the + revenue of producing doesn't create a profit. + """ + + roundtrip_efficieny = 0.9 + epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() + epex_da_production = Sensor.query.filter( + Sensor.name == "epex_da_production" + ).one_or_none() + battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() + + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 3)) + + # Case 1: Consumption Price = Production Price, roundtrip_efficieny < 1 + schedule1, soc_schedule_1 = run_test_charge_discharge_sign( + roundtrip_efficieny, epex_da.id, epex_da.id + ) + + # For the negative price period, the schedule shows oscillations + # discharge in even hours + assert all(schedule1[:8:2] < 0) # 12am, 2am, 4am, 6am + + # charge in odd hours + assert all(schedule1[1:8:2] > 0) # 1am, 3am, 5am, 7am + + # in positive price hours, the battery will only discharge to sell the energy charged in the negative hours + assert all(schedule1.loc[start + timedelta(hours=8) :] <= 0) + + # Case 2: Consumption Price = Production Price, roundtrip_efficieny = 1 + schedule2, soc_schedule_2 = run_test_charge_discharge_sign( + 1, epex_da.id, epex_da.id + ) + assert all(schedule2[:8] == [-2, 0, 2, 0, 0, 0, 0, 0]) # no oscillation + + # Case 3: Consumption Price > Production Price, roundtrip_efficieny < 1 + # In this case, we expect the battery to hold the energy that has initially and sell it during the period of + # positive prices. + schedule3, soc_schedule_3 = run_test_charge_discharge_sign( + roundtrip_efficieny, epex_da.id, epex_da_production.id + ) + assert all(np.isclose(schedule3[:8], 0)) # no oscillation + assert all(schedule3[8:] <= 0) + + # discharge the whole battery in 1 time period + assert np.isclose( + schedule3.min(), + -battery.get_attribute("capacity_in_mw") * np.sqrt(roundtrip_efficieny), + ) + + # Case 4: Consumption Price > Production Price, roundtrip_efficieny < 1 + schedule4, soc_schedule_4 = run_test_charge_discharge_sign( + 1, epex_da.id, epex_da_production.id + ) + + assert all(np.isclose(schedule4[:8], 0)) # no oscillation + assert all(schedule4[8:] <= 0) + + # discharge the whole battery in 1 time period, with no conversion losses + assert np.isclose(schedule4.min(), -battery.get_attribute("capacity_in_mw")) + @pytest.mark.parametrize( "target_soc, charging_station_name", From 6c344c5d89fd8a274ef0df873cd6ee7670b59793 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 31 Jul 2023 09:36:52 +0200 Subject: [PATCH 28/51] reorder installation instructions for CBC and HiGHS Signed-off-by: Victor Garcia Reolid --- documentation/dev/introduction.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/documentation/dev/introduction.rst b/documentation/dev/introduction.rst index 2c9623d35..a335573c7 100644 --- a/documentation/dev/introduction.rst +++ b/documentation/dev/introduction.rst @@ -48,17 +48,19 @@ Go into the ``flexmeasures`` folder and install all dependencies including the o $ cd flexmeasures $ make install-for-dev -:ref:`Install the LP solver `. On Unix the Cbc LP solver can be installed with: +:ref:`Install the LP solver `. On Linux, the HiGHS solver can be installed with: .. code-block:: bash - $ apt-get install coinor-cbc + $ pip install highspy + -Alternatively, HiGHS solver can be installed with pip: +Alternatively, the CBC solver can be installed with: .. code-block:: bash - $ pip install highspy + $ apt-get install coinor-cbc + Configuration ^^^^^^^^^^^^^^^^^^^^ From fa968002c9fcf8ded60165be5d27a704d02b422c Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 31 Jul 2023 09:38:10 +0200 Subject: [PATCH 29/51] add production price fixture Signed-off-by: Victor Garcia Reolid --- flexmeasures/conftest.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 58ab17948..70c6d81f7 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -259,7 +259,18 @@ def create_test_markets(db) -> dict[str, Market]: knowledge_horizon_par={"x": 1, "y": 12, "z": "Europe/Paris"}, ) db.session.add(epex_da) - return {"epex_da": epex_da} + + epex_da_production = Market( + name="epex_da_production", + market_type_name="day_ahead", + event_resolution=timedelta(hours=1), + unit="EUR/MWh", + knowledge_horizon_fnc="x_days_ago_at_y_oclock", + knowledge_horizon_par={"x": 1, "y": 12, "z": "Europe/Paris"}, + ) + db.session.add(epex_da_production) + + return {"epex_da": epex_da, "epex_da_production": epex_da_production} @pytest.fixture(scope="module") @@ -558,6 +569,8 @@ def add_market_prices( end=pd.Timestamp("2015-01-04").tz_localize("Europe/Amsterdam"), resolution="1H", ) + + # consumption prices values = [-10] * 8 + [100] * 16 day3_beliefs = [ TimedBelief( @@ -570,7 +583,27 @@ def add_market_prices( for dt, val in zip(time_slots, values) ] db.session.add_all(day3_beliefs) - return {"epex_da": setup_markets["epex_da"].corresponding_sensor} + + # production prices = consumption prices - 40 + values = [-50] * 8 + [60] * 16 + day3_beliefs_production = [ + TimedBelief( + event_start=dt, + belief_horizon=timedelta(hours=0), + event_value=val, + source=setup_sources["Seita"], + sensor=setup_markets["epex_da_production"].corresponding_sensor, + ) + for dt, val in zip(time_slots, values) + ] + db.session.add_all(day3_beliefs_production) + + return { + "epex_da": setup_markets["epex_da"].corresponding_sensor, + "epex_da (production)": setup_markets[ + "epex_da_production" + ].corresponding_sensor, + } @pytest.fixture(scope="module") From 2f5c1c0a28a3e6c322b7ef1b08df3083dff357b2 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 31 Jul 2023 15:45:05 +0200 Subject: [PATCH 30/51] fix loading results twice Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/linear_optimization.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 491db4342..971de7caa 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -435,10 +435,6 @@ def device_scheduler( # noqa C901 initial_stock, ) - # load the results only if a feasible solution has been found - if len(results.solution) > 0: - model.solutions.load_from(results) - planned_costs = value(model.costs) planned_power_per_device = [] for d in model.d: From 5705de98fd7b87d7595331e95bb63a05523a6599 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Mon, 31 Jul 2023 15:48:03 +0200 Subject: [PATCH 31/51] remove TODO Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/tests/test_solver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 05c99fba8..5f7e43d00 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -251,7 +251,6 @@ def run_test_charge_discharge_sign( return schedule.tz_convert(tz), soc_schedule.tz_convert(tz) -# todo: think if it is necessary to test with inflexible_device def test_battery_solver_day_3( add_battery_assets, add_inflexible_device_forecasts, From dc44545f58aa0e73d99890dc5e7d1487bb553c88 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Tue, 1 Aug 2023 14:22:58 +0200 Subject: [PATCH 32/51] increment StorageScheduler version Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 722d072fc..285429266 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -26,7 +26,7 @@ class StorageScheduler(Scheduler): - __version__ = "1" + __version__ = "2" __author__ = "Seita" COLUMNS = [ From c0c48004f4acdbac625451f1ce7807f45efff904 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Tue, 1 Aug 2023 14:25:27 +0200 Subject: [PATCH 33/51] add changelog entry Signed-off-by: Victor Garcia Reolid --- documentation/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 363273181..ead0be2b3 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -7,6 +7,7 @@ v0.15.0 | July XX, 2023 ============================ .. warning:: Upgrading to this version requires running ``flexmeasures db upgrade`` (you can create a backup first with ``flexmeasures db-ops dump``). +.. warning:: Upgrading to this version requires installing the LP/MILP solver HiGHS using ``pip install highspy``. New features ------------- @@ -22,6 +23,8 @@ New features Bugfixes ----------- +* Add binary constraint to avoid energy leakages. [see `PR #770 `_] + Infrastructure / Support ---------------------- From 48fd692ea798a58b8d245a68be6e2bb41fd5d073 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Tue, 1 Aug 2023 17:47:10 +0200 Subject: [PATCH 34/51] fix conftest Signed-off-by: Victor Garcia Reolid --- .../api/common/schemas/tests/test_sensors.py | 2 +- flexmeasures/conftest.py | 10 ++--- flexmeasures/data/models/generic_assets.py | 41 +++++++++++++++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/flexmeasures/api/common/schemas/tests/test_sensors.py b/flexmeasures/api/common/schemas/tests/test_sensors.py index 598d6221a..1872fa49e 100644 --- a/flexmeasures/api/common/schemas/tests/test_sensors.py +++ b/flexmeasures/api/common/schemas/tests/test_sensors.py @@ -30,7 +30,7 @@ ), "connection", "fm0", - "Test battery with no known prices", + "Test battery", ), ], ) diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 70c6d81f7..93de5f5ac 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -249,7 +249,9 @@ def create_test_markets(db) -> dict[str, Market]: weekly_seasonality=True, yearly_seasonality=True, ) + db.session.add(day_ahead) + epex_da = Market( name="epex_da", market_type_name="day_ahead", @@ -598,11 +600,9 @@ def add_market_prices( ] db.session.add_all(day3_beliefs_production) - return { + yield { "epex_da": setup_markets["epex_da"].corresponding_sensor, - "epex_da (production)": setup_markets[ - "epex_da_production" - ].corresponding_sensor, + "epex_da_production": setup_markets["epex_da_production"].corresponding_sensor, } @@ -615,7 +615,7 @@ def add_battery_assets( @pytest.fixture(scope="function") def add_battery_assets_fresh_db( - fresh_db, setup_roles_users_fresh_db, setup_markets_fresh_db + fresh_db, setup_roles_users_fresh_db, setup_markets_fresh_db, create ) -> dict[str, Asset]: return create_test_battery_assets( fresh_db, setup_roles_users_fresh_db, setup_markets_fresh_db diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index fe7e792a8..531aa5ab0 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -528,6 +528,33 @@ def get_timerange(cls, sensors: List["Sensor"]) -> Dict[str, datetime]: # noqa return dict(start=start, end=end) +def get_or_create_generic_asset( + name: str, + generic_asset_type_id: int, + attributes: dict, + flush: bool = True, +) -> DataSource: + + _generic_asset = ( + GenericAsset.query.filter(GenericAsset.name == name) + .filter(GenericAsset.generic_asset_type_id == generic_asset_type_id) + .one_or_none() + ) + + if _generic_asset is None: + _generic_asset = GenericAsset( + name=name, + generic_asset_type_id=generic_asset_type_id, + attributes=attributes, + ) + db.session.add(_generic_asset) + if flush: + # assigns id so that we can reference the new object in the current db session + db.session.flush() + + return _generic_asset + + def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset: """Create a GenericAsset and assigns it an id. @@ -550,16 +577,24 @@ def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset: ).one_or_none() if generic_asset_type is None: raise ValueError(f"Cannot find GenericAssetType {asset_type_name} in database.") - new_generic_asset = GenericAsset( + + new_generic_asset = get_or_create_generic_asset( name=kwargs["name"], generic_asset_type_id=generic_asset_type.id, attributes=kwargs["attributes"] if "attributes" in kwargs else {}, ) + + # GenericAsset( + # name=kwargs["name"], + # generic_asset_type_id=generic_asset_type.id, + # attributes=kwargs["attributes"] if "attributes" in kwargs else {}, + # ) + for arg in ("latitude", "longitude", "account_id"): if arg in kwargs: setattr(new_generic_asset, arg, kwargs[arg]) - db.session.add(new_generic_asset) - db.session.flush() # generates the pkey for new_generic_asset + # db.session.add(new_generic_asset) + # db.session.flush() # generates the pkey for new_generic_asset return new_generic_asset From b8c546e39be1440de183754d4a26e897f7f15e51 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Tue, 1 Aug 2023 17:47:50 +0200 Subject: [PATCH 35/51] update test_hashing with a new hash due to the change in the version of the StorageScheduler Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/tests/test_scheduling_repeated_jobs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/tests/test_scheduling_repeated_jobs.py b/flexmeasures/data/tests/test_scheduling_repeated_jobs.py index 9ede63229..8f5bad701 100644 --- a/flexmeasures/data/tests/test_scheduling_repeated_jobs.py +++ b/flexmeasures/data/tests/test_scheduling_repeated_jobs.py @@ -156,7 +156,8 @@ def test_hashing(db, app, add_charging_station_assets, setup_test_data): print("RIGHT HASH: ", hash) # checks that hashes are consistent between different runtime calls - assert hash == "4ed0V9h247brxusBYk3ug9Cy7miPnynOeSNBT8hd5Mo=" + # this test needs to be updated in case of a version upgrade + assert hash == "mmENiTzn49DRv7zbPZn05yVK7RXrdxglalRfmteuFms=" kwargs2 = copy.deepcopy(kwargs) args2 = copy.deepcopy(args) From 613039e26b2a5f418078523fdded4e63969448ce Mon Sep 17 00:00:00 2001 From: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Date: Tue, 1 Aug 2023 17:44:08 +0200 Subject: [PATCH 36/51] feat: in play mode, allow showing any sensor on the asset page (#740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR uses the FlexMeasures play mode to allow showing sensors across organisations. When showing simulation results, we want to be able to show results from different scenarios (stored under different accounts) on the same asset page. For example, when comparing a scenario against some other benchmark scenario. * feat: in play mode, allow showing any sensor on the asset page Signed-off-by: F.N. Claessen * fix checking for config value Signed-off-by: Nicolas Höning * more consistent naming of account variable Signed-off-by: Nicolas Höning * chore: rename variable of accessible sensors for readability Signed-off-by: Nicolas Höning * docs: mention this new possibility in function docstring Signed-off-by: Nicolas Höning * fix: do not fail if sensors are not accessible, log a warning for them as well Signed-off-by: Nicolas Höning * docs: changelog entry Signed-off-by: F.N. Claessen * docs: changelog warning Signed-off-by: F.N. Claessen --------- Signed-off-by: F.N. Claessen Signed-off-by: Nicolas Höning Co-authored-by: Nicolas Höning --- documentation/changelog.rst | 3 ++ documentation/host/modes.rst | 1 + flexmeasures/data/models/generic_assets.py | 40 ++++++++++++++++++---- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 92244b108..f34133f5a 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -9,11 +9,14 @@ v0.15.0 | July XX, 2023 .. warning:: Upgrading to this version requires running ``flexmeasures db upgrade`` (you can create a backup first with ``flexmeasures db-ops dump``). .. warning:: Upgrading to this version requires installing the LP/MILP solver HiGHS using ``pip install highspy``. +.. warning:: If your server is running in play mode (``FLEXMEASURES_MODE = "play"``), users will be able to see sensor data from any account [see `PR #740 `_]. + New features ------------- * Allow deleting multiple sensors with a single call to ``flexmeasures delete sensor`` by passing the ``--id`` option multiple times [see `PR #734 `_] * Make it a lot easier to read off the color legend on the asset page, especially when showing many sensors, as they will now be ordered from top to bottom in the same order as they appear in the chart (as defined in the ``sensors_to_show`` attribute), rather than alphabetically [see `PR #742 `_] +* Users on FlexMeasures servers in play mode (``FLEXMEASURES_MODE = "play"``) can use the ``sensors_to_show`` attribute to show any sensor on their asset pages, rather than only sensors registered to assets in their own account or to public assets [see `PR #740 `_] * Having percentages within the [0, 100] domain is such a common use case that we now always include it in sensor charts with % units, making it easier to read off individual charts and also to compare across charts [see `PR #739 `_] * DataSource table now allows storing arbitrary attributes as a JSON (without content validation), similar to the Sensor and GenericAsset tables [see `PR #750 `_] * Added API endpoints `/sensors/` for fetching a single sensor and `/sensors` (POST) for adding a sensor. [see `PR #759 `_] and [see `PR #767 `_] diff --git a/documentation/host/modes.rst b/documentation/host/modes.rst index 0b1b227ea..e126e5a9c 100644 --- a/documentation/host/modes.rst +++ b/documentation/host/modes.rst @@ -39,3 +39,4 @@ Small features - [API] Posted UDI events are not enforced to be consecutive. - [API] Names in ``GetConnectionResponse`` are the connections' unique database names rather than their display names (this feature is planned to be deprecated). - [UI] The dashboard plot showing the latest power value is not enforced to lie in the past (in case of simulating future values). +- [UI] On the asset page, the ``sensors_to_show`` attribute can be used to show any sensor from any account, rather than only sensors from assets owned by the user's organization. diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 531aa5ab0..27a89bdb7 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -4,6 +4,7 @@ from typing import Any, Dict, Optional, Tuple, List, Union import json +from flask import current_app from flask_security import current_user import pandas as pd from sqlalchemy.engine import Row @@ -434,7 +435,7 @@ def sensors_to_show(self) -> list["Sensor" | list["Sensor"]]: # noqa F821 Sensors to show are defined as a list of sensor ids, which is set by the "sensors_to_show" field of the asset's "attributes" column. Valid sensors either belong to the asset itself, to other assets in the same account, - or to public assets. + or to public assets. In play mode, sensors from different accounts can be added. In case the field is missing, defaults to two of the asset's sensors. Sensor ids can be nested to denote that sensors should be 'shown together', @@ -453,25 +454,52 @@ def sensors_to_show(self) -> list["Sensor" | list["Sensor"]]: # noqa F821 if not self.has_attribute("sensors_to_show"): return self.sensors[:2] + # Only allow showing sensors from assets owned by the user's organization, + # except in play mode, where any sensor may be shown + accounts = [self.owner] + if current_app.config.get("FLEXMEASURES_MODE") == "play": + from flexmeasures.data.models.user import Account + + accounts = Account.query.all() + from flexmeasures.data.services.sensors import get_sensors sensor_ids_to_show = self.get_attribute("sensors_to_show") - sensor_map = { + accessible_sensor_map = { sensor.id: sensor for sensor in get_sensors( - account=self.owner, + account=accounts, include_public_assets=True, sensor_id_allowlist=flatten_unique(sensor_ids_to_show), ) } - # Return sensors in the order given by the sensors_to_show attribute, and with the same nesting + # Build list of sensor objects that are accessible sensors_to_show = [] + missed_sensor_ids = [] + + # we make sure to build in the order given by the sensors_to_show attribute, and with the same nesting for s in sensor_ids_to_show: if isinstance(s, list): - sensors_to_show.append([sensor_map[sensor_id] for sensor_id in s]) + inaccessible = [sid for sid in s if sid not in accessible_sensor_map] + missed_sensor_ids.extend(inaccessible) + if len(inaccessible) < len(s): + sensors_to_show.append( + [ + accessible_sensor_map[sensor_id] + for sensor_id in s + if sensor_id in accessible_sensor_map + ] + ) else: - sensors_to_show.append(sensor_map[s]) + if s not in accessible_sensor_map: + missed_sensor_ids.append(s) + else: + sensors_to_show.append(accessible_sensor_map[s]) + if missed_sensor_ids: + current_app.logger.warning( + f"Cannot include sensor(s) {missed_sensor_ids} in sensors_to_show on asset {self}, as it is not accessible to user {current_user}." + ) return sensors_to_show @property From c679b66bb08de8ecaf71bab300e5112752874adf Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Tue, 1 Aug 2023 18:03:37 +0200 Subject: [PATCH 37/51] fix fixture Signed-off-by: Victor Garcia Reolid --- flexmeasures/cli/tests/conftest.py | 49 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/flexmeasures/cli/tests/conftest.py b/flexmeasures/cli/tests/conftest.py index 1e502150e..2fc5d4445 100644 --- a/flexmeasures/cli/tests/conftest.py +++ b/flexmeasures/cli/tests/conftest.py @@ -10,26 +10,42 @@ @pytest.fixture(scope="module") @pytest.mark.skip_github -def setup_dummy_data(db, app): +def setup_dummy_asset(db, app): """ - Create an asset with two sensors (1 and 2), and add the same set of 200 beliefs with an hourly resolution to each of them. - Return the two sensors and a result sensor (which has no data). + Create an Asset to add sensors to and return the id. """ - dummy_asset_type = GenericAssetType(name="DummyGenericAssetType") - report_asset_type = GenericAssetType(name="ReportAssetType") - db.session.add_all([dummy_asset_type, report_asset_type]) + db.session.add(dummy_asset_type) dummy_asset = GenericAsset( name="DummyGenericAsset", generic_asset_type=dummy_asset_type ) + db.session.add(dummy_asset) + db.session.commit() + + return dummy_asset.id + + +@pytest.fixture(scope="module") +@pytest.mark.skip_github +def setup_dummy_data(db, app, setup_dummy_asset): + """ + Create an asset with two sensors (1 and 2), and add the same set of 200 beliefs with an hourly resolution to each of them. + Return the two sensors and a result sensor (which has no data). + """ + + report_asset_type = GenericAssetType(name="ReportAssetType") + + db.session.add(report_asset_type) pandas_report = GenericAsset( name="PandasReport", generic_asset_type=report_asset_type ) - db.session.add_all([dummy_asset, pandas_report]) + db.session.add(pandas_report) + + dummy_asset = GenericAsset.query.get(setup_dummy_asset) sensor1 = Sensor( "sensor 1", generic_asset=dummy_asset, event_resolution=timedelta(hours=1) @@ -96,22 +112,3 @@ def reporter_config_raw(app, db, setup_dummy_data): ) return reporter_config_raw - - -@pytest.fixture(scope="module") -@pytest.mark.skip_github -def setup_dummy_asset(db, app): - """ - Create an Asset to add sensors to and return the id. - """ - dummy_asset_type = GenericAssetType(name="DummyGenericAssetType") - - db.session.add(dummy_asset_type) - - dummy_asset = GenericAsset( - name="DummyGenericAsset", generic_asset_type=dummy_asset_type - ) - db.session.add(dummy_asset) - db.session.commit() - - return dummy_asset.id From 76e29f0d0ffd90501ae65383146d869548e65d57 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 1 Aug 2023 20:19:48 +0200 Subject: [PATCH 38/51] fix: changelog Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 7e1ce0b07..9f9800a5e 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -7,9 +7,8 @@ v0.15.0 | July XX, 2023 ============================ .. warning:: Upgrading to this version requires running ``flexmeasures db upgrade`` (you can create a backup first with ``flexmeasures db-ops dump``). -.. warning:: Upgrading to this version requires installing the LP/MILP solver HiGHS using ``pip install highspy``. -.. warning:: If your server is running in play mode (``FLEXMEASURES_MODE = "play"``), users will be able to see sensor data from any account [see `PR #740 `_]. +.. warning:: Upgrading to this version requires installing the LP/MILP solver HiGHS using ``pip install highspy``. .. warning:: If your server is running in play mode (``FLEXMEASURES_MODE = "play"``), users will be able to see sensor data from any account [see `PR #740 `_]. @@ -28,7 +27,7 @@ New features Bugfixes ----------- -* Add binary constraint to avoid energy leakages. [see `PR #770 `_] +* Add binary constraint to avoid energy leakages during periods with negative prices [see `PR #770 `_] Infrastructure / Support ---------------------- From 7a0f9299eac9d23191873cab3f68c69acc50a3df Mon Sep 17 00:00:00 2001 From: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Date: Wed, 2 Aug 2023 09:47:40 +0200 Subject: [PATCH 39/51] revert (the refactoring part of aa328391dd4d0eb1ce7cbe71c0e37628d7b35c60): prepend model to the return tuple of the device_scheduler; this prevents duplicating a large docstring at the cost of introducing a breaking change in the function signature of the device_scheduler (but no external code uses that function to the best of my knowledge) (#781) Signed-off-by: F.N. Claessen --- .../models/planning/linear_optimization.py | 71 ++----------------- flexmeasures/data/models/planning/storage.py | 2 +- .../data/models/planning/tests/test_solver.py | 4 +- 3 files changed, 8 insertions(+), 69 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 971de7caa..233d6e8b2 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -27,17 +27,15 @@ infinity = float("inf") -def run_device_scheduler( # noqa C901 +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]], initial_stock: float = 0, -) -> Tuple[ConcreteModel, SolverResults]: - """This function solves the device scheduler model, returning the solution and the result metadata. - - This generic device scheduler is able to handle an EMS with multiple devices, +) -> Tuple[List[pd.Series], float, SolverResults, ConcreteModel]: + """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. @@ -372,69 +370,10 @@ def cost_function(m): model, load_solutions=False ) - # load the results only if the termination condition is not infeasible + # load the results only if a feasible solution has been found if len(results.solution) > 0: model.solutions.load_from(results) - return model, results - - -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]], - initial_stock: float = 0, -) -> 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, - 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): - max: maximum stock assuming an initial stock of zero (e.g. in MWh or boxes) - min: minimum stock assuming an initial stock of zero - equal: exact amount of stock (we do this by clamping min and max) - efficiency: amount of stock left at the next datetime (the rest is lost) - 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: conversion efficiency of flow out of a device (flow out : stock decrease) - derivative up efficiency: conversion efficiency of flow into a device (stock increase : flow in) - EMS constraints are on an EMS level. Handled constraints (listed by column name): - derivative max: maximum flow - derivative min: minimum flow - 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 - - 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 - - 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 - DataFrame. Later we could pass in a MultiIndex DataFrame directly. - """ - - start = device_constraints[0].index.to_pydatetime()[0] - # Workaround for https://github.com/pandas-dev/pandas/issues/53643. Was: resolution = pd.to_timedelta(device_constraints[0].index.freq) - resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta() - end = device_constraints[0].index.to_pydatetime()[-1] + resolution - - model, results = run_device_scheduler( # noqa C901 - device_constraints, - ems_constraints, - commitment_quantities, - commitment_downwards_deviation_price, - commitment_upwards_deviation_price, - initial_stock, - ) - planned_costs = value(model.costs) planned_power_per_device = [] for d in model.d: @@ -452,4 +391,4 @@ def device_scheduler( # noqa C901 # model.display() # print(results.solver.termination_condition) # print(planned_costs) - return planned_power_per_device, planned_costs, results + return planned_power_per_device, planned_costs, results, model diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 285429266..227b0712c 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -238,7 +238,7 @@ def compute(self, skip_validation: bool = False) -> pd.Series | None: commitment_upwards_deviation_price, ) = self._prepare(skip_validation=skip_validation) - ems_schedule, expected_costs, scheduler_results = device_scheduler( + ems_schedule, expected_costs, scheduler_results, _ = device_scheduler( device_constraints, ems_constraints, commitment_quantities, diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 5f7e43d00..f272f8475 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -13,7 +13,7 @@ add_storage_constraints, validate_storage_constraints, ) -from flexmeasures.data.models.planning.linear_optimization import run_device_scheduler +from flexmeasures.data.models.planning.linear_optimization import device_scheduler from flexmeasures.data.models.planning.tests.utils import check_constraints from flexmeasures.data.models.planning.utils import initialize_series, initialize_df from flexmeasures.utils.calculations import ( @@ -211,7 +211,7 @@ def run_test_charge_discharge_sign( commitment_upwards_deviation_price, ) = scheduler._prepare(skip_validation=True) - model, results = run_device_scheduler( + _, _, results, model = device_scheduler( device_constraints, ems_constraints, commitment_quantities, From 5b9f65ac6e2f85ba0957661af7d66b6da0471f8c Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Wed, 2 Aug 2023 10:48:51 +0200 Subject: [PATCH 40/51] clean code and latitude, longitude and account_id to get_or_create_generic_asset Signed-off-by: Victor Garcia Reolid --- flexmeasures/data/models/generic_assets.py | 41 +++++++++++++--------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 27a89bdb7..979a0b2e0 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -560,22 +560,38 @@ def get_or_create_generic_asset( name: str, generic_asset_type_id: int, attributes: dict, + latitude: float = None, + longitude: float = None, + account_id: int = None, flush: bool = True, -) -> DataSource: +) -> GenericAsset: - _generic_asset = ( - GenericAsset.query.filter(GenericAsset.name == name) - .filter(GenericAsset.generic_asset_type_id == generic_asset_type_id) - .one_or_none() + _query = GenericAsset.query.filter(GenericAsset.name == name).filter( + GenericAsset.generic_asset_type_id == generic_asset_type_id ) + if latitude is not None: + _query = _query.filter(GenericAsset.latitude == latitude) + if longitude is not None: + _query = _query.filter(GenericAsset.longitude == longitude) + if account_id is not None: + _query = _query.filter(GenericAsset.account_id == account_id) + + _generic_asset = _query.one_or_none() + if _generic_asset is None: + _generic_asset = GenericAsset( name=name, generic_asset_type_id=generic_asset_type_id, attributes=attributes, + latitude=latitude, + longitude=longitude, + account_id=account_id, ) + db.session.add(_generic_asset) + if flush: # assigns id so that we can reference the new object in the current db session db.session.flush() @@ -610,19 +626,12 @@ def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset: name=kwargs["name"], generic_asset_type_id=generic_asset_type.id, attributes=kwargs["attributes"] if "attributes" in kwargs else {}, + latitude=kwargs.get("latitude"), + longitude=kwargs.get("longitude"), + account_id=kwargs.get("account_id"), + flush=True, ) - # GenericAsset( - # name=kwargs["name"], - # generic_asset_type_id=generic_asset_type.id, - # attributes=kwargs["attributes"] if "attributes" in kwargs else {}, - # ) - - for arg in ("latitude", "longitude", "account_id"): - if arg in kwargs: - setattr(new_generic_asset, arg, kwargs[arg]) - # db.session.add(new_generic_asset) - # db.session.flush() # generates the pkey for new_generic_asset return new_generic_asset From 8cb58e784f7dc07e69fd9790b07999e95ba67486 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Wed, 2 Aug 2023 22:15:37 +0200 Subject: [PATCH 41/51] add highspy to test.txt and test.in Signed-off-by: Victor Garcia Reolid --- requirements/test.in | 4 +++- requirements/test.txt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/requirements/test.in b/requirements/test.in index 21197c2bd..d657cd3f1 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -12,4 +12,6 @@ requests_mock # lets tests run successfully in containers fakeredis # required with fakeredis, maybe because we use rq -lupa \ No newline at end of file +lupa +# LP solver required to test the function device_scheduler which is used by the StorageScheduler +highspy \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt index 3dc640eaa..06d3ea4f2 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -30,6 +30,8 @@ flask==2.1.2 # via # -c requirements/app.txt # pytest-flask +highspy==1.5.3 + # via -r requirements/test.in idna==3.4 # via # -c requirements/app.txt From 8b7f14f09a8beb6a22b0b5852998c7a2e386e323 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 11:28:38 +0200 Subject: [PATCH 42/51] fix: typos Signed-off-by: F.N. Claessen --- .../data/models/planning/tests/test_solver.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index bd8ac3003..99a626e39 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -271,7 +271,7 @@ def test_battery_solver_day_3( revenue of producing doesn't create a profit. """ - roundtrip_efficieny = 0.9 + roundtrip_efficiency = 0.9 epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() epex_da_production = Sensor.query.filter( Sensor.name == "epex_da_production" @@ -281,9 +281,9 @@ def test_battery_solver_day_3( tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 3)) - # Case 1: Consumption Price = Production Price, roundtrip_efficieny < 1 + # Case 1: Consumption Price = Production Price, roundtrip_efficiency < 1 schedule1, soc_schedule_1 = run_test_charge_discharge_sign( - roundtrip_efficieny, epex_da.id, epex_da.id + roundtrip_efficiency, epex_da.id, epex_da.id ) # For the negative price period, the schedule shows oscillations @@ -296,17 +296,17 @@ def test_battery_solver_day_3( # in positive price hours, the battery will only discharge to sell the energy charged in the negative hours assert all(schedule1.loc[start + timedelta(hours=8) :] <= 0) - # Case 2: Consumption Price = Production Price, roundtrip_efficieny = 1 + # Case 2: Consumption Price = Production Price, roundtrip_efficiency = 1 schedule2, soc_schedule_2 = run_test_charge_discharge_sign( 1, epex_da.id, epex_da.id ) assert all(schedule2[:8] == [-2, 0, 2, 0, 0, 0, 0, 0]) # no oscillation - # Case 3: Consumption Price > Production Price, roundtrip_efficieny < 1 + # Case 3: Consumption Price > Production Price, roundtrip_efficiency < 1 # In this case, we expect the battery to hold the energy that has initially and sell it during the period of # positive prices. schedule3, soc_schedule_3 = run_test_charge_discharge_sign( - roundtrip_efficieny, epex_da.id, epex_da_production.id + roundtrip_efficiency, epex_da.id, epex_da_production.id ) assert all(np.isclose(schedule3[:8], 0)) # no oscillation assert all(schedule3[8:] <= 0) @@ -314,10 +314,10 @@ def test_battery_solver_day_3( # discharge the whole battery in 1 time period assert np.isclose( schedule3.min(), - -battery.get_attribute("capacity_in_mw") * np.sqrt(roundtrip_efficieny), + -battery.get_attribute("capacity_in_mw") * np.sqrt(roundtrip_efficiency), ) - # Case 4: Consumption Price > Production Price, roundtrip_efficieny < 1 + # Case 4: Consumption Price > Production Price, roundtrip_efficiency < 1 schedule4, soc_schedule_4 = run_test_charge_discharge_sign( 1, epex_da.id, epex_da_production.id ) From 66226ae415e2424f7ccbab825fe482d03b235c0f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 11:45:07 +0200 Subject: [PATCH 43/51] fix: test that needs to get the battery object Signed-off-by: F.N. Claessen --- .../data/models/planning/tests/test_solver.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 99a626e39..600a9b58c 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -169,10 +169,8 @@ def test_battery_solver_day_2( def run_test_charge_discharge_sign( - roundtrip_efficiency, consumption_price_sensor_id, production_price_sensor_id + battery, roundtrip_efficiency, consumption_price_sensor_id, production_price_sensor_id ): - battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() - tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 3)) end = tz.localize(datetime(2015, 1, 4)) @@ -276,14 +274,14 @@ def test_battery_solver_day_3( epex_da_production = Sensor.query.filter( Sensor.name == "epex_da_production" ).one_or_none() - battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() + battery = add_battery_assets["Test battery"].sensors[0] tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 3)) # Case 1: Consumption Price = Production Price, roundtrip_efficiency < 1 schedule1, soc_schedule_1 = run_test_charge_discharge_sign( - roundtrip_efficiency, epex_da.id, epex_da.id + battery, roundtrip_efficiency, epex_da.id, epex_da.id ) # For the negative price period, the schedule shows oscillations @@ -298,7 +296,7 @@ def test_battery_solver_day_3( # Case 2: Consumption Price = Production Price, roundtrip_efficiency = 1 schedule2, soc_schedule_2 = run_test_charge_discharge_sign( - 1, epex_da.id, epex_da.id + battery, 1, epex_da.id, epex_da.id ) assert all(schedule2[:8] == [-2, 0, 2, 0, 0, 0, 0, 0]) # no oscillation @@ -306,7 +304,7 @@ def test_battery_solver_day_3( # In this case, we expect the battery to hold the energy that has initially and sell it during the period of # positive prices. schedule3, soc_schedule_3 = run_test_charge_discharge_sign( - roundtrip_efficiency, epex_da.id, epex_da_production.id + battery, roundtrip_efficiency, epex_da.id, epex_da_production.id ) assert all(np.isclose(schedule3[:8], 0)) # no oscillation assert all(schedule3[8:] <= 0) @@ -319,7 +317,7 @@ def test_battery_solver_day_3( # Case 4: Consumption Price > Production Price, roundtrip_efficiency < 1 schedule4, soc_schedule_4 = run_test_charge_discharge_sign( - 1, epex_da.id, epex_da_production.id + battery, 1, epex_da.id, epex_da_production.id ) assert all(np.isclose(schedule4[:8], 0)) # no oscillation From 1923fb1603da2a4857722fda2423b3bc871655c1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 11:53:41 +0200 Subject: [PATCH 44/51] fix: typo Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 600a9b58c..b7950b91e 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -265,7 +265,7 @@ def test_battery_solver_day_3( same buy/sell prices. Nonetheless, as shown in Cases 3) and 4), the oscillatory dynamic is gone when having Consumption Price > Production Price. - This is because even though the energy consumed is bigger than the produced, the difference between the cost of consuming and the + This is because even though the energy consumed is bigger than that produced, the difference between the cost of consuming and the revenue of producing doesn't create a profit. """ From e24bf66dbf8f986036a28218ce171fba45a4b877 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 11:59:40 +0200 Subject: [PATCH 45/51] docs: clarify choice of SoC values in test Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index b7950b91e..4c13e95f7 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -176,6 +176,10 @@ def run_test_charge_discharge_sign( end = tz.localize(datetime(2015, 1, 4)) resolution = timedelta(hours=1) storage_efficiency = 1 + # Choose the SoC constraints and starting value such that the battery can fully charge or discharge in a single time step + soc_min = 0 + soc_max = battery.get_attribute("capacity_in_mw") + soc_at_start = battery.get_attribute("capacity_in_mw") scheduler: Scheduler = StorageScheduler( battery, @@ -183,9 +187,9 @@ def run_test_charge_discharge_sign( end, resolution, flex_model={ - "soc-at-start": battery.get_attribute("capacity_in_mw"), - "soc-min": 0, - "soc-max": battery.get_attribute("capacity_in_mw"), + "soc-min": soc_min, + "soc-max": soc_max, + "soc-at-start": soc_at_start, "roundtrip-efficiency": roundtrip_efficiency, "storage-efficiency": storage_efficiency, "prefer-charging-sooner": False, From 7f70cb68b8dc5ba5660f432ffa7633a17361bd3a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 12:06:28 +0200 Subject: [PATCH 46/51] fix: test case 2 says it tests for no oscillation, which is only guaranteed when prefer-charging-sooner is true; otherwise, the solver will be completely indifferent to oscilations during the period with negative prices Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 4c13e95f7..26768fa5c 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -192,7 +192,7 @@ def run_test_charge_discharge_sign( "soc-at-start": soc_at_start, "roundtrip-efficiency": roundtrip_efficiency, "storage-efficiency": storage_efficiency, - "prefer-charging-sooner": False, + "prefer-charging-sooner": True, }, flex_context={ "consumption-price-sensor": consumption_price_sensor_id, @@ -302,7 +302,7 @@ def test_battery_solver_day_3( schedule2, soc_schedule_2 = run_test_charge_discharge_sign( battery, 1, epex_da.id, epex_da.id ) - assert all(schedule2[:8] == [-2, 0, 2, 0, 0, 0, 0, 0]) # no oscillation + assert all(np.isclose(schedule2[:8], 0)) # no oscillation # Case 3: Consumption Price > Production Price, roundtrip_efficiency < 1 # In this case, we expect the battery to hold the energy that has initially and sell it during the period of From e29575b400ddeaa2ad38dc6d0f55acac97dbb32c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 12:08:07 +0200 Subject: [PATCH 47/51] style: streamline variable names in test Signed-off-by: F.N. Claessen --- .../data/models/planning/tests/test_solver.py | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 26768fa5c..cc43f3f42 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -284,51 +284,51 @@ def test_battery_solver_day_3( start = tz.localize(datetime(2015, 1, 3)) # Case 1: Consumption Price = Production Price, roundtrip_efficiency < 1 - schedule1, soc_schedule_1 = run_test_charge_discharge_sign( + schedule_1, soc_schedule_1 = run_test_charge_discharge_sign( battery, roundtrip_efficiency, epex_da.id, epex_da.id ) # For the negative price period, the schedule shows oscillations # discharge in even hours - assert all(schedule1[:8:2] < 0) # 12am, 2am, 4am, 6am + assert all(schedule_1[:8:2] < 0) # 12am, 2am, 4am, 6am # charge in odd hours - assert all(schedule1[1:8:2] > 0) # 1am, 3am, 5am, 7am + assert all(schedule_1[1:8:2] > 0) # 1am, 3am, 5am, 7am # in positive price hours, the battery will only discharge to sell the energy charged in the negative hours - assert all(schedule1.loc[start + timedelta(hours=8) :] <= 0) + assert all(schedule_1.loc[start + timedelta(hours=8) :] <= 0) # Case 2: Consumption Price = Production Price, roundtrip_efficiency = 1 - schedule2, soc_schedule_2 = run_test_charge_discharge_sign( + schedule_2, soc_schedule_2 = run_test_charge_discharge_sign( battery, 1, epex_da.id, epex_da.id ) - assert all(np.isclose(schedule2[:8], 0)) # no oscillation + assert all(np.isclose(schedule_2[:8], 0)) # no oscillation # Case 3: Consumption Price > Production Price, roundtrip_efficiency < 1 # In this case, we expect the battery to hold the energy that has initially and sell it during the period of # positive prices. - schedule3, soc_schedule_3 = run_test_charge_discharge_sign( + schedule_3, soc_schedule_3 = run_test_charge_discharge_sign( battery, roundtrip_efficiency, epex_da.id, epex_da_production.id ) - assert all(np.isclose(schedule3[:8], 0)) # no oscillation - assert all(schedule3[8:] <= 0) + assert all(np.isclose(schedule_3[:8], 0)) # no oscillation + assert all(schedule_3[8:] <= 0) # discharge the whole battery in 1 time period assert np.isclose( - schedule3.min(), + schedule_3.min(), -battery.get_attribute("capacity_in_mw") * np.sqrt(roundtrip_efficiency), ) # Case 4: Consumption Price > Production Price, roundtrip_efficiency < 1 - schedule4, soc_schedule_4 = run_test_charge_discharge_sign( + schedule_4, soc_schedule_4 = run_test_charge_discharge_sign( battery, 1, epex_da.id, epex_da_production.id ) - assert all(np.isclose(schedule4[:8], 0)) # no oscillation - assert all(schedule4[8:] <= 0) + assert all(np.isclose(schedule_4[:8], 0)) # no oscillation + assert all(schedule_4[8:] <= 0) # discharge the whole battery in 1 time period, with no conversion losses - assert np.isclose(schedule4.min(), -battery.get_attribute("capacity_in_mw")) + assert np.isclose(schedule_4.min(), -battery.get_attribute("capacity_in_mw")) @pytest.mark.parametrize( @@ -666,7 +666,7 @@ def compute_schedule(flex_model): "soc-max": soc_max, } - soc_schedule1 = compute_schedule(flex_model) + soc_schedule_1 = compute_schedule(flex_model) # soc maxima and soc minima soc_maxima = [ @@ -687,31 +687,31 @@ def compute_schedule(flex_model): "soc-targets": soc_targets, } - soc_schedule2 = compute_schedule(flex_model) + soc_schedule_2 = compute_schedule(flex_model) # check that, in this case, adding the constraints # alter the SOC profile - assert not soc_schedule2.equals(soc_schedule1) + assert not soc_schedule_2.equals(soc_schedule_1) # check that global minimum is achieved - assert soc_schedule1.min() == soc_min - assert soc_schedule2.min() == soc_min + assert soc_schedule_1.min() == soc_min + assert soc_schedule_2.min() == soc_min # check that global maximum is achieved - assert soc_schedule1.max() == soc_max - assert soc_schedule2.max() == soc_max + assert soc_schedule_1.max() == soc_max + assert soc_schedule_2.max() == soc_max # test for soc_minima # check that the local minimum constraint is respected - assert soc_schedule2.loc["2015-01-02T08:00:00+01:00"] >= 3.5 + assert soc_schedule_2.loc["2015-01-02T08:00:00+01:00"] >= 3.5 # test for soc_maxima # check that the local maximum constraint is respected - assert soc_schedule2.loc["2015-01-02T15:00:00+01:00"] <= 1.0 + assert soc_schedule_2.loc["2015-01-02T15:00:00+01:00"] <= 1.0 # test for soc_targets # check that the SOC target (at 19 pm, local time) is met - assert soc_schedule2.loc["2015-01-02T19:00:00+01:00"] == 2.0 + assert soc_schedule_2.loc["2015-01-02T19:00:00+01:00"] == 2.0 @pytest.mark.parametrize( From 90dad65018798b6ab77cb1528979e9a82335275f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 12:26:03 +0200 Subject: [PATCH 48/51] revert 48fd692ea798a58b8d245a68be6e2bb41fd5d073: unnecessary after #695 Signed-off-by: F.N. Claessen --- flexmeasures/data/models/generic_assets.py | 54 +++------------------- 1 file changed, 6 insertions(+), 48 deletions(-) diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 26812e285..dafe771cc 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -559,49 +559,6 @@ def get_timerange(cls, sensors: List["Sensor"]) -> Dict[str, datetime]: # noqa return dict(start=start, end=end) -def get_or_create_generic_asset( - name: str, - generic_asset_type_id: int, - attributes: dict, - latitude: float = None, - longitude: float = None, - account_id: int = None, - flush: bool = True, -) -> GenericAsset: - - _query = GenericAsset.query.filter(GenericAsset.name == name).filter( - GenericAsset.generic_asset_type_id == generic_asset_type_id - ) - - if latitude is not None: - _query = _query.filter(GenericAsset.latitude == latitude) - if longitude is not None: - _query = _query.filter(GenericAsset.longitude == longitude) - if account_id is not None: - _query = _query.filter(GenericAsset.account_id == account_id) - - _generic_asset = _query.one_or_none() - - if _generic_asset is None: - - _generic_asset = GenericAsset( - name=name, - generic_asset_type_id=generic_asset_type_id, - attributes=attributes, - latitude=latitude, - longitude=longitude, - account_id=account_id, - ) - - db.session.add(_generic_asset) - - if flush: - # assigns id so that we can reference the new object in the current db session - db.session.flush() - - return _generic_asset - - def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset: """Create a GenericAsset and assigns it an id. @@ -625,15 +582,16 @@ def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset: if generic_asset_type is None: raise ValueError(f"Cannot find GenericAssetType {asset_type_name} in database.") - new_generic_asset = get_or_create_generic_asset( + new_generic_asset = GenericAsset( name=kwargs["name"], generic_asset_type_id=generic_asset_type.id, attributes=kwargs["attributes"] if "attributes" in kwargs else {}, - latitude=kwargs.get("latitude"), - longitude=kwargs.get("longitude"), - account_id=kwargs.get("account_id"), - flush=True, ) + for arg in ("latitude", "longitude", "account_id"): + if arg in kwargs: + setattr(new_generic_asset, arg, kwargs[arg]) + db.session.add(new_generic_asset) + db.session.flush() # generates the pkey for new_generic_asset return new_generic_asset From e7c3d3b51f9cfb542159995ab5dcd86b5620ee42 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 13:06:13 +0200 Subject: [PATCH 49/51] style: black Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index cc43f3f42..f459f21d2 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -169,7 +169,10 @@ def test_battery_solver_day_2( def run_test_charge_discharge_sign( - battery, roundtrip_efficiency, consumption_price_sensor_id, production_price_sensor_id + battery, + roundtrip_efficiency, + consumption_price_sensor_id, + production_price_sensor_id, ): tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 3)) From c5c4851cb30136705d526ecd16bfd7615a97607b Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Thu, 3 Aug 2023 13:22:42 +0200 Subject: [PATCH 50/51] remove installation of highspy as it's already being installed through the test requirements. Signed-off-by: Victor Garcia Reolid --- .github/workflows/lint-and-test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 6e594a972..10a6c7cc4 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -49,7 +49,6 @@ jobs: ${{ runner.os }}-pip- - run: | ci/setup-postgres.sh - pip install highspy - name: Install FlexMeasures & exact dependencies for tests run: make install-for-test if: github.event_name == 'push' && steps.cache.outputs.cache-hit != 'true' From d39dcb15e7ffb54f66db7354b791a48256dd7112 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 3 Aug 2023 14:55:39 +0200 Subject: [PATCH 51/51] fix (docs): comment out cross reference to masked documentation page Signed-off-by: F.N. Claessen --- documentation/concepts/benefits.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documentation/concepts/benefits.rst b/documentation/concepts/benefits.rst index 25085708c..8bd845c36 100644 --- a/documentation/concepts/benefits.rst +++ b/documentation/concepts/benefits.rst @@ -34,4 +34,6 @@ The platform operator (as ESCo or Aggregator) and asset owners can share the pro FlexMeasures plans on providing basic accounting for this. -.. note:: Read more on flexibility opportunities and activations, as well as profit sharing on :ref:`benefits_of_flex` +.. + + note:: Read more on flexibility opportunities and activations, as well as profit sharing on :ref:`benefits_of_flex`