diff --git a/.readthedocs.yml b/.readthedocs.yml index 86c001e07..2702d5ab2 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -18,6 +18,11 @@ formats: all # Optionally set the version of Python and requirements required to build your docs python: - version: "3.8" install: - requirements: rtd_requirements.txt + +# Set the version of Python +build: + os: ubuntu-22.04 + tools: + python: "3.9" \ No newline at end of file diff --git a/doc/whatsnew/v0-3-0.rst b/doc/whatsnew/v0-3-0.rst index e23270bf9..19f0fd230 100644 --- a/doc/whatsnew/v0-3-0.rst +++ b/doc/whatsnew/v0-3-0.rst @@ -24,3 +24,4 @@ Changes * Added option to run reinforcement with reduced number of time steps `#379 `_ * Added optimization methods to determine dispatch of flexibilities that lead to minimal network expansion costs. `#376 `_ * Added a new reinforcement method that separate lv grids when the overloading is very high `#380 `_ +* Added a storage operation strategy where the storage is charged when PV feed-in is higher than electricity demand of the household and discharged when electricity demand exceeds PV generation `#386 `_ diff --git a/edisgo/flex_opt/battery_storage_operation.py b/edisgo/flex_opt/battery_storage_operation.py new file mode 100644 index 000000000..64447a8fd --- /dev/null +++ b/edisgo/flex_opt/battery_storage_operation.py @@ -0,0 +1,255 @@ +import logging +import math + +import pandas as pd + +logger = logging.getLogger(__name__) + + +def _reference_operation( + df, + soe_init, + soe_max, + storage_p_nom, + freq, + efficiency_store, + efficiency_dispatch, +): + """ + Reference operation of storage system where it is directly charged when PV feed-in + is higher than electricity demand of the building. + + Battery model handles generation positive, demand negative. + + Parameters + ----------- + df : :pandas:`pandas.DataFrame` + Dataframe with time index and the buildings residual electricity demand + (PV generation minus electricity demand) in column "feedin_minus_demand". + soe_init : float + Initial state of energy of storage device in MWh. + soe_max : float + Maximum energy level of storage device in MWh. + storage_p_nom : float + Nominal charging power of storage device in MW. + freq : float + Frequency of provided time series. Set to one, in case of hourly time series or + 0.5 in case of half-hourly time series. + efficiency_store : float + Efficiency of storage system in case of charging. + efficiency_dispatch : float + Efficiency of storage system in case of discharging. + + Returns + --------- + :pandas:`pandas.DataFrame` + Dataframe provided through parameter `df` extended by columns "storage_power", + holding the charging (negative values) and discharging (positive values) power + of the storage unit in MW, and "storage_soe" holding the storage unit's state of + energy in MWh. + + """ + lst_storage_power = [] + lst_storage_soe = [] + storage_soe = soe_init + + for i, d in df.iterrows(): + # If the house were to feed electricity into the grid, charge the storage first. + # No electricity exchange with grid as long as charger power is not exceeded. + if (d.feedin_minus_demand > 0.0) & (storage_soe < soe_max): + # Check if energy produced exceeds charger power + if d.feedin_minus_demand < storage_p_nom: + storage_power = -d.feedin_minus_demand + # If it does, feed the rest to the grid + else: + storage_power = -storage_p_nom + storage_soe = storage_soe + (-storage_power * efficiency_store * freq) + # If the storage is overcharged, feed the 'rest' to the grid + if storage_soe > soe_max: + storage_power = storage_power + (storage_soe - soe_max) / ( + efficiency_store * freq + ) + storage_soe = soe_max + + # If the house needs electricity from the grid, discharge the storage first. + # In this case d.feedin_minus_demand is negative! + # No electricity exchange with grid as long as demand does not exceed charging + # power + elif (d.feedin_minus_demand < 0.0) & (storage_soe > 0.0): + # Check if energy demand exceeds charger power + if d.feedin_minus_demand / efficiency_dispatch < (storage_p_nom * -1): + storage_soe = storage_soe - (storage_p_nom * freq) + storage_power = storage_p_nom * efficiency_dispatch + else: + storage_soe = storage_soe + ( + d.feedin_minus_demand / efficiency_dispatch * freq + ) + storage_power = -d.feedin_minus_demand + # If the storage is undercharged, take the 'rest' from the grid + if storage_soe < 0.0: + # since storage_soe is negative in this case it can be taken as + # demand + storage_power = storage_power + storage_soe * efficiency_dispatch / freq + storage_soe = 0.0 + + # If the storage is full or empty, the demand is not affected + else: + storage_power = 0.0 + lst_storage_power.append(storage_power) + lst_storage_soe.append(storage_soe) + + df["storage_power"] = lst_storage_power + df["storage_soe"] = lst_storage_soe + + return df.round(6) + + +def apply_reference_operation( + edisgo_obj, storage_units_names=None, soe_init=0.0, freq=1 +): + """ + Applies reference storage operation to specified home storage units. + + In the reference storage operation, the home storage system is directly charged when + PV feed-in is higher than electricity demand of the building until the storage + is fully charged. The storage is directly discharged, in case electricity demand + of the building is higher than the PV feed-in, until it is fully discharged. + The battery model handles generation positive and demand negative. + + To determine the PV feed-in and electricity demand of the building that the home + storage is located in (including demand from heat pumps + and electric vehicles), this function matches the storage units to PV plants and + building electricity demand using the building ID. + In case there is no electricity load or no PV system, the storage operation is set + to zero. + + The resulting storage units' active power time series are written to + :attr:`~.network.timeseries.TimeSeries.loads_active_power`. + Further, reactive power time series are set up using function + :attr:`~.edisgo.EDisGo.set_time_series_reactive_power_control` with default values. + The state of energy time series that are calculated within this function are not + written anywhere, but are returned by this function. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + EDisGo object to obtain storage units and PV feed-in and electricity demand + in same building from. + storage_units_names : list(str) or None + Names of storage units as in + :attr:`~.network.topology.Topology.storage_units_df` to set time for. If None, + time series are set for all storage units in + :attr:`~.network.topology.Topology.storage_units_df`. + soe_init : float + Initial state of energy of storage device in MWh. Default: 0 MWh. + freq : float + Frequency of provided time series. Set to one, in case of hourly time series or + 0.5 in case of half-hourly time series. Default: 1. + + Returns + -------- + :pandas:`pandas.DataFrame` + Dataframe with time index and state of energy in MWh of each storage in columns. + Column names correspond to storage name as in + :attr:`~.network.topology.Topology.storage_units_df`. + + Notes + ------ + This function requires that the storage parameters `building_id`, + `efficiency_store`, `efficiency_dispatch` and `max_hours` are set in + :attr:`~.network.topology.Topology.storage_units_df` for all storage units + specified in parameter `storage_units_names`. + + """ + if storage_units_names is None: + storage_units_names = edisgo_obj.topology.storage_units_df.index + + storage_units = edisgo_obj.topology.storage_units_df.loc[storage_units_names] + soe_df = pd.DataFrame(index=edisgo_obj.timeseries.timeindex) + + for stor_name, stor_data in storage_units.iterrows(): + # get corresponding PV systems and electric loads + building_id = stor_data["building_id"] + pv_gens = edisgo_obj.topology.generators_df.loc[ + edisgo_obj.topology.generators_df.building_id == building_id + ].index + loads = edisgo_obj.topology.loads_df.loc[ + edisgo_obj.topology.loads_df.building_id == building_id + ].index + if len(loads) == 0 or len(pv_gens) == 0: + if len(loads) == 0: + logger.warning( + f"Storage unit {stor_name} in building {building_id} has no load. " + f"Storage operation is therefore set to zero." + ) + if len(pv_gens) == 0: + logger.warning( + f"Storage unit {stor_name} in building {building_id} has no PV " + f"system. Storage operation is therefore set to zero." + ) + edisgo_obj.set_time_series_manual( + storage_units_p=pd.DataFrame( + columns=[stor_name], + index=soe_df.index, + data=0.0, + ) + ) + else: + # check storage values + if math.isnan(stor_data.max_hours) is True: + raise ValueError( + f"Parameter max_hours for storage unit {stor_name} is not a " + f"number. It needs to be set in Topology.storage_units_df." + ) + if math.isnan(stor_data.efficiency_store) is True: + raise ValueError( + f"Parameter efficiency_store for storage unit {stor_name} is not a " + f"number. It needs to be set in Topology.storage_units_df." + ) + if math.isnan(stor_data.efficiency_dispatch) is True: + raise ValueError( + f"Parameter efficiency_dispatch for storage unit {stor_name} is " + f"not a number. It needs to be set in Topology.storage_units_df." + ) + pv_feedin = edisgo_obj.timeseries.generators_active_power[pv_gens].sum( + axis=1 + ) + house_demand = edisgo_obj.timeseries.loads_active_power[loads].sum(axis=1) + # apply operation strategy + storage_ts = _reference_operation( + df=pd.DataFrame( + columns=["feedin_minus_demand"], data=pv_feedin - house_demand + ), + soe_init=soe_init, + soe_max=stor_data.p_nom * stor_data.max_hours, + storage_p_nom=stor_data.p_nom, + freq=freq, + efficiency_store=stor_data.efficiency_store, + efficiency_dispatch=stor_data.efficiency_dispatch, + ) + # add storage time series to storage_units_active_power dataframe + edisgo_obj.set_time_series_manual( + storage_units_p=pd.DataFrame( + columns=[stor_name], + index=storage_ts.index, + data=storage_ts.storage_power.values, + ) + ) + soe_df = pd.concat( + [soe_df, storage_ts.storage_soe.to_frame(stor_name)], axis=1 + ) + + edisgo_obj.set_time_series_reactive_power_control( + generators_parametrisation=None, + loads_parametrisation=None, + storage_units_parametrisation=pd.DataFrame( + { + "components": [storage_units_names], + "mode": ["default"], + "power_factor": ["default"], + }, + index=[1], + ), + ) + + return soe_df diff --git a/edisgo/network/topology.py b/edisgo/network/topology.py index 28cefb687..6a5cdde80 100755 --- a/edisgo/network/topology.py +++ b/edisgo/network/topology.py @@ -51,7 +51,14 @@ "subtype", "source_id", ], - "storage_units_df": ["bus", "control", "p_nom", "max_hours"], + "storage_units_df": [ + "bus", + "control", + "p_nom", + "max_hours", + "efficiency_store", + "efficiency_dispatch", + ], "transformers_df": ["bus0", "bus1", "x_pu", "r_pu", "s_nom", "type_info"], "lines_df": [ "bus0", @@ -367,6 +374,14 @@ def storage_units_df(self): Maximum state of charge capacity in terms of hours at full output capacity p_nom. + efficiency_store : float + Efficiency of storage system in case of charging. So far only used in + :func:`~.edisgo.flex_opt.battery_storage_operation.apply_reference_operation.` + + efficiency_dispatch : float + Efficiency of storage system in case of discharging. So far only used in + :func:`~.edisgo.flex_opt.battery_storage_operation.apply_reference_operation.` + Returns -------- :pandas:`pandas.DataFrame` diff --git a/edisgo/tools/tools.py b/edisgo/tools/tools.py index 68fc2aa85..ef18a601d 100644 --- a/edisgo/tools/tools.py +++ b/edisgo/tools/tools.py @@ -318,6 +318,11 @@ def _assign_to_lines(lines): lambda _: edisgo_obj.topology.buses_df.at[_.bus1, mode], axis=1 ) + # assign np.nan values to new columns, so that missing values can be found through + # isna() + edisgo_obj.topology.lines_df[mode] = np.nan + edisgo_obj.topology.buses_df[mode] = np.nan + if mode == "mv_feeder": graph = edisgo_obj.topology.mv_grid.graph station = edisgo_obj.topology.mv_grid.station.index[0] diff --git a/examples/electromobility_example.ipynb b/examples/electromobility_example.ipynb index c2170bf82..70738b6ab 100644 --- a/examples/electromobility_example.ipynb +++ b/examples/electromobility_example.ipynb @@ -43,7 +43,7 @@ "outputs": [], "source": [ "import os\n", - "\n", + "import json\n", "import geopandas as gpd\n", "import pandas as pd\n", "import requests\n", @@ -53,7 +53,6 @@ "\n", "from copy import deepcopy\n", "from pathlib import Path\n", - "from bs4 import BeautifulSoup\n", "\n", "from edisgo.edisgo import EDisGo\n", "from edisgo.tools.logger import setup_logger\n", @@ -415,11 +414,6 @@ "source": [ "# Download SimBEV data\n", "\n", - "def listFD(url, ext=\"\"):\n", - " page = requests.get(url).text\n", - " soup = BeautifulSoup(page, \"html.parser\")\n", - " return [node.get(\"href\").split(\"/\")[-1] for node in soup.find_all(\"a\") if node.get(\"href\").endswith(ext)]\n", - "\n", "def download_simbev_example_data():\n", "\n", " raw_url = (\"https://raw.githubusercontent.com/openego/eDisGo/dev/\" +\n", @@ -435,7 +429,9 @@ " # download files\n", " url = (f\"https://github.com/openego/eDisGo/tree/dev/\" +\n", " f\"tests/data/simbev_example_scenario/{ags}/\")\n", - " filenames = [f for f in listFD(url, \"csv\")]\n", + " page = requests.get(url).text\n", + " items = json.loads(page)[\"payload\"][\"tree\"][\"items\"]\n", + " filenames = [f[\"name\"] for f in items if \"csv\" in f[\"name\"]]\n", "\n", " for file in filenames:\n", " req = requests.get(f\"{raw_url}/{ags}/{file}\")\n", @@ -473,7 +469,9 @@ " # download files\n", " url = (\"https://github.com/openego/eDisGo/tree/dev/\" +\n", " \"tests/data/tracbev_example_scenario/\")\n", - " filenames = [f for f in listFD(url, \"gpkg\")]\n", + " page = requests.get(url).text\n", + " items = json.loads(page)[\"payload\"][\"tree\"][\"items\"]\n", + " filenames = [f[\"name\"] for f in items if \"gpkg\" in f[\"name\"]]\n", "\n", " for file in filenames:\n", " req = requests.get(\n", @@ -493,7 +491,9 @@ "cell_type": "code", "execution_count": null, "id": "1d65e6d6", - "metadata": {}, + "metadata": { + "scrolled": true + }, "outputs": [], "source": [ "edisgo.import_electromobility(\n", @@ -776,7 +776,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.5" + "version": "3.8.18" }, "toc": { "base_numbering": 1, diff --git a/rtd_requirements.txt b/rtd_requirements.txt index 966400840..dd3c393d5 100644 --- a/rtd_requirements.txt +++ b/rtd_requirements.txt @@ -1,7 +1,5 @@ -beautifulsoup4 dash < 2.9.0 demandlib -docutils == 0.16.0 egoio >= 0.4.7 geopy >= 2.0.0 jupyter_dash @@ -17,7 +15,7 @@ pypsa >=0.17.0, <=0.20.1 pyyaml saio scikit-learn -sphinx >= 4.3.0, < 5.1.0 +sphinx sphinx_rtd_theme >=0.5.2 sphinx-autodoc-typehints sphinx-autoapi diff --git a/setup.py b/setup.py index 72bbdfbe1..e28c14695 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,6 @@ def read(fname): requirements = [ - "beautifulsoup4", "contextily", "dash < 2.9.0", "demandlib", @@ -55,7 +54,7 @@ def read(fname): "pypsa >= 0.17.0, <= 0.20.1", "pyyaml", "saio", - "scikit-learn", + "scikit-learn <= 1.1.1", "shapely >= 1.7.0", "sqlalchemy < 1.4.0", "sshtunnel", @@ -72,7 +71,7 @@ def read(fname): "pytest", "pytest-notebook", "pyupgrade", - "sphinx >= 4.3.0, < 5.1.0", + "sphinx", "sphinx_rtd_theme >=0.5.2", "sphinx-autodoc-typehints", "sphinx-autoapi", diff --git a/tests/flex_opt/test_battery_storage_operation.py b/tests/flex_opt/test_battery_storage_operation.py new file mode 100644 index 000000000..2268d7bc6 --- /dev/null +++ b/tests/flex_opt/test_battery_storage_operation.py @@ -0,0 +1,218 @@ +import numpy as np +import pandas as pd +import pytest + +from edisgo import EDisGo +from edisgo.flex_opt.battery_storage_operation import apply_reference_operation + + +class TestStorageOperation: + @classmethod + def setup_class(self): + self.timeindex = pd.date_range("1/1/2011 12:00", periods=5, freq="H") + self.edisgo = EDisGo( + ding0_grid=pytest.ding0_test_network_path, timeindex=self.timeindex + ) + self.edisgo.topology.storage_units_df = pd.DataFrame( + data={ + "bus": [ + "Bus_BranchTee_LVGrid_2_4", + "Bus_BranchTee_LVGrid_2_4", + "Bus_BranchTee_LVGrid_2_4", + "Bus_BranchTee_LVGrid_2_4", + "Bus_BranchTee_LVGrid_2_4", + ], + "control": ["PQ", "PQ", "PQ", "PQ", "PQ"], + "p_nom": [0.2, 2.0, 0.4, 0.5, 0.6], + "max_hours": [6, 6, 1, 6, 6], + "efficiency_store": [0.9, 1.0, 0.9, 1.0, 0.8], + "efficiency_dispatch": [0.9, 1.0, 0.9, 1.0, 0.8], + "building_id": [1, 2, 3, 4, 5], + }, + index=["stor1", "stor2", "stor3", "stor4", "stor5"], + ) + # set building IDs + self.edisgo.topology.loads_df.at[ + "Load_residential_LVGrid_8_2", "building_id" + ] = 2 + self.edisgo.topology.loads_df.at[ + "Load_residential_LVGrid_8_3", "building_id" + ] = 2 + self.edisgo.topology.generators_df.at[ + "GeneratorFluctuating_25", "building_id" + ] = 2 + self.edisgo.topology.generators_df.at[ + "GeneratorFluctuating_26", "building_id" + ] = 2 + self.edisgo.topology.loads_df.at[ + "Load_residential_LVGrid_3_2", "building_id" + ] = 3.0 + self.edisgo.topology.generators_df.at[ + "GeneratorFluctuating_17", "building_id" + ] = 3.0 + self.edisgo.topology.loads_df.at[ + "Load_residential_LVGrid_1_6", "building_id" + ] = 4 + self.edisgo.topology.loads_df.at[ + "Load_residential_LVGrid_1_4", "building_id" + ] = 5.0 + self.edisgo.topology.generators_df.at[ + "GeneratorFluctuating_27", "building_id" + ] = 5.0 + # set time series + self.edisgo.timeseries.loads_active_power = pd.DataFrame( + data={ + "Load_residential_LVGrid_8_2": [0.5, 1.0, 1.5, 0.0, 0.5], + "Load_residential_LVGrid_8_3": [0.5, 1.0, 1.5, 0.0, 0.5], + "Load_residential_LVGrid_3_2": [0.5, 0.0, 1.0, 0.5, 0.5], + "Load_residential_LVGrid_1_4": [0.0, 1.0, 1.5, 0.0, 0.5], + }, + index=self.timeindex, + ) + self.edisgo.timeseries.generators_active_power = pd.DataFrame( + data={ + "GeneratorFluctuating_25": [1.5, 3.0, 4.5, 0.0, 0.0], + "GeneratorFluctuating_26": [0.5, 1.0, 1.5, 0.0, 0.5], + "GeneratorFluctuating_17": [0.0, 1.0, 1.5, 1.0, 0.0], + "GeneratorFluctuating_27": [0.5, 0.0, 0.5, 0.0, 0.0], + }, + index=self.timeindex, + ) + + def test_operating_strategy(self): + # test without load (stor1) + # test with several loads and several PV systems (stor2) + # test with one load and one PV system (stor3) + # test without PV system (stor4) + # test with one value not numeric (stor5) + + # test with providing storage name + apply_reference_operation(edisgo_obj=self.edisgo, storage_units_names=["stor1"]) + + check_ts = pd.DataFrame( + data={ + "stor1": [0.0, 0.0, 0.0, 0.0, 0.0], + }, + index=self.timeindex, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_active_power, + check_ts, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_reactive_power, + check_ts, + ) + + # test without providing storage names + soe_df = apply_reference_operation(edisgo_obj=self.edisgo) + + assert soe_df.shape == (5, 3) + assert self.edisgo.timeseries.storage_units_active_power.shape == (5, 5) + assert self.edisgo.timeseries.storage_units_reactive_power.shape == (5, 5) + + # check stor2 + s = "stor2" + check_ts = pd.DataFrame( + data={ + s: [-1.0, -2.0, -2.0, 0.0, 0.5], + }, + index=self.timeindex, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_active_power.loc[:, [s]], + check_ts, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_reactive_power.loc[:, [s]], + check_ts * -np.tan(np.arccos(0.95)), + ) + check_ts = pd.DataFrame( + data={ + s: [1.0, 3.0, 5.0, 5.0, 4.5], + }, + index=self.timeindex, + ) + pd.testing.assert_frame_equal( + soe_df.loc[:, [s]], + check_ts, + ) + + # check stor3 + s = "stor3" + check_ts = pd.DataFrame( + data={ + s: [0.0, -0.4, -0.044444, 0.0, 0.36], + }, + index=self.timeindex, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_active_power.loc[:, [s]], + check_ts, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_reactive_power.loc[:, [s]], + check_ts * -np.tan(np.arccos(0.95)), + ) + check_ts = pd.DataFrame( + data={ + s: [0.0, 0.36, 0.4, 0.4, 0.0], + }, + index=self.timeindex, + ) + pd.testing.assert_frame_equal( + soe_df.loc[:, [s]], + check_ts, + ) + + # check stor4 - all zeros + s = "stor4" + check_ts = pd.DataFrame( + data={ + s: [0.0, 0.0, 0.0, 0.0, 0.0], + }, + index=self.timeindex, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_active_power.loc[:, [s]], + check_ts, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_reactive_power.loc[:, [s]], + check_ts, + ) + # check stor5 + s = "stor5" + check_ts = pd.DataFrame( + data={ + s: [-0.5, 0.32, 0.0, 0.0, 0.0], + }, + index=self.timeindex, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_active_power.loc[:, [s]], + check_ts, + ) + pd.testing.assert_frame_equal( + self.edisgo.timeseries.storage_units_reactive_power.loc[:, [s]], + check_ts * -np.tan(np.arccos(0.95)), + ) + check_ts = pd.DataFrame( + data={ + s: [0.4, 0.0, 0.0, 0.0, 0.0], + }, + index=self.timeindex, + ) + pd.testing.assert_frame_equal( + soe_df.loc[:, [s]], + check_ts, + ) + + # test error raising + self.edisgo.topology.storage_units_df.at["stor5", "max_hours"] = np.nan + msg = ( + "Parameter max_hours for storage unit stor5 is not a number. It needs " + "to be set in Topology.storage_units_df." + ) + with pytest.raises(ValueError, match=msg): + apply_reference_operation(self.edisgo)