diff --git a/doc/whatsnew/v0-3-0.rst b/doc/whatsnew/v0-3-0.rst index 1335a26c7..fb1b0bb91 100644 --- a/doc/whatsnew/v0-3-0.rst +++ b/doc/whatsnew/v0-3-0.rst @@ -28,4 +28,7 @@ Changes * Move function to assign feeder to Topology class and add methods to the Grid class to get information on the feeders `#360 `_ * 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 `_ * Added an estimation of the voltage deviation over a cable when selecting a suitable cable to connect a new component `#411 `_ -* Added clipping of heat pump electrical power at its maximum value #428 +* Added clipping of heat pump electrical power at its maximum value `#428 `_ +* Loading predefined time series now automatically sets the timeindex to the default year of the database if it is empty. `#457 `_ +* Made OEP database call optional in get_database_alias_dictionaries, allowing setup without OEP when using an alternative eGon-data database. `#451 `_ +* Fixed database import issues by addressing table naming assumptions and added support for external SSH tunneling in eGon-data configurations. `#451 `_ diff --git a/edisgo/config/config_grid_expansion_default.cfg b/edisgo/config/config_grid_expansion_default.cfg index 74a612eb7..c4d511bde 100644 --- a/edisgo/config/config_grid_expansion_default.cfg +++ b/edisgo/config/config_grid_expansion_default.cfg @@ -96,6 +96,31 @@ lv_load_case_line = 1.0 lv_feed-in_case_transformer = 1.0 lv_feed-in_case_line = 1.0 +# §14a EnWG curtailment +# ===================== +# Settings for curtailment of controllable consumption devices according to §14a EnWG +# as an alternative to grid expansion + +[curtailment_14a_enwg] + +# enable_curtailment: whether to consider §14a curtailment as an option in grid +# reinforcement. If True, curtailment can be used instead of grid expansion +# to solve grid issues. +enable_curtailment = True + +# max_power_kw: maximum allowed power in kW after curtailment according to §14a EnWG. +# The legal default is 4.2 kW for controllable consumption devices. +max_power_kw = 4.2 + +# curtailment_priority: defines how to prioritize components for curtailment. +# Options: 'p_set' (largest nominal power first), 'random', 'grid_level' +# (start with LV, then MV) +curtailment_priority = p_set + +# components_type: which types of components can be curtailed. +# Options: 'heat_pump', 'charging_point', 'both' +components_type = both + # costs # ============ diff --git a/edisgo/config/config_opf_julia_default.cfg b/edisgo/config/config_opf_julia_default.cfg index 5a24d842e..e0c1eacee 100644 --- a/edisgo/config/config_opf_julia_default.cfg +++ b/edisgo/config/config_opf_julia_default.cfg @@ -11,4 +11,4 @@ [julia_dir] -julia_bin = julia-1.1.0/bin +julia_bin = julia/bin diff --git a/edisgo/edisgo.py b/edisgo/edisgo.py index eaa1111ef..2e3d27f72 100755 --- a/edisgo/edisgo.py +++ b/edisgo/edisgo.py @@ -17,6 +17,11 @@ from edisgo.flex_opt.charging_strategies import charging_strategy from edisgo.flex_opt.check_tech_constraints import lines_relative_load +from edisgo.flex_opt.curtailment_14a import ( + apply_curtailment_14a, + check_curtailment_effect, + identify_components_for_curtailment, +) from edisgo.flex_opt.heat_pump_operation import ( operating_strategy as hp_operating_strategy, ) @@ -41,6 +46,7 @@ ) from edisgo.io.heat_pump_import import oedb as import_heat_pumps_oedb from edisgo.io.storage_import import home_batteries_oedb +from edisgo.io.timeseries_import import _timeindex_helper_func from edisgo.network import timeseries from edisgo.network.dsm import DSM from edisgo.network.electromobility import Electromobility @@ -71,6 +77,11 @@ class EDisGo: ---------- ding0_grid : :obj:`str` Path to directory containing csv files of network to be loaded. + engine : :sqlalchemy:`sqlalchemy.Engine` or None + Database engine for connecting to the `OpenEnergy DataBase OEDB + `_ or other eGon-data + databases. Defaults to the OEDB engine. Can be set to None if no scenario is to + be loaded. generator_scenario : None or :obj:`str`, optional If None, the generator park of the imported grid is kept as is. Otherwise defines which scenario of future generator park to use @@ -158,8 +169,10 @@ class EDisGo: """ def __init__(self, **kwargs): + # Set database engine for future scenarios + self.engine: Engine | None = kwargs.pop("engine", egon_engine()) # load configuration - self._config = Config(**kwargs) + self._config = Config(engine=self.engine, **kwargs) # instantiate topology object and load grid data self.topology = Topology(config=self.config) @@ -418,12 +431,9 @@ def set_time_series_active_power_predefined( Technology- and weather cell-specific hourly feed-in time series are obtained from the `OpenEnergy DataBase - `_. See - :func:`edisgo.io.timeseries_import.feedin_oedb` for more information. - - This option requires that the parameter `engine` is provided in case - new ding0 grids with geo-referenced LV grids are used. For further - settings, the parameter `timeindex` can also be provided. + `_ or other eGon-data + databases. See :func:`edisgo.io.timeseries_import.feedin_oedb` for more + information. * :pandas:`pandas.DataFrame` @@ -536,9 +546,6 @@ def set_time_series_active_power_predefined( Other Parameters ------------------ - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. This parameter is only required in case - `conventional_loads_ts` or `fluctuating_generators_ts` is 'oedb'. scenario : str Scenario for which to retrieve demand data. Possible options are 'eGon2035' and 'eGon100RE'. This parameter is only required in case @@ -556,15 +563,45 @@ def set_time_series_active_power_predefined( is indexed using a default year and set for the whole year. """ + timeindex = kwargs.get("timeindex", None) engine = kwargs["engine"] if "engine" in kwargs else egon_engine() - if self.timeseries.timeindex.empty: + if timeindex is not None and not self.timeseries.timeindex.empty: logger.warning( - "When setting time series using predefined profiles it is better to " - "set a time index as all data in TimeSeries class is indexed by the" - "time index. You can set the time index upon initialisation of " - "the EDisGo object by providing the input parameter 'timeindex' or by " - "using the function EDisGo.set_timeindex()." + "The given timeindex is different from the EDisGo.TimeSeries.timeindex." + " Therefore the EDisGo.TimeSeries.timeindex will be overwritten by the " + "given timeindex." ) + + set_timeindex = True + + elif self.timeseries.timeindex.empty: + logger.warning( + "The EDisGo.TimeSeries.timeindex is empty. By default, this function " + "will set the timeindex to the default year of the provided database " + "connection. To ensure expected behavior, consider setting the " + "timeindex explicitly before running this function using " + "EDisGo.set_timeindex()." + ) + + set_timeindex = True + + else: + set_timeindex = False + + if set_timeindex: + if timeindex is None: + timeindex, _ = _timeindex_helper_func( + self, timeindex, allow_leap_year=True + ) + + logger.warning(f"Setting EDisGo.TimeSeries.timeindex to {timeindex}.") + + self.set_timeindex(timeindex) + + logger.info( + f"Trying to set predefined timeseries for {self.timeseries.timeindex}" + ) + if fluctuating_generators_ts is not None: self.timeseries.predefined_fluctuating_generators_by_technology( self, @@ -796,6 +833,7 @@ def to_powermodels( flexible_loads=None, flexible_storage_units=None, opf_version=1, + curtailment_14a=None, ): """ Convert eDisGo representation of the network topology and timeseries to @@ -822,6 +860,11 @@ def to_powermodels( Version of optimization models to choose from. Must be one of [1, 2, 3, 4]. For more information see :func:`edisgo.opf.powermodels_opf.pm_optimize`. Default: 1. + curtailment_14a : dict or None + Configuration for §14a EnWG curtailment of heat pumps and charging points. + Dictionary should contain keys 'apply_curtailment' (bool), 'max_power_mw' + (float), and 'components' (list of component names). + Default: None. Returns ------- @@ -838,6 +881,7 @@ def to_powermodels( flexible_loads=flexible_loads, flexible_storage_units=flexible_storage_units, opf_version=opf_version, + curtailment_14a=curtailment_14a, ) def pm_optimize( @@ -854,6 +898,7 @@ def pm_optimize( save_heat_storage=True, save_slack_gen=True, save_slacks=True, + curtailment_14a=None, ): """ Run OPF in julia subprocess and write results of OPF back to edisgo object. @@ -901,6 +946,11 @@ def pm_optimize( hence there will be no logging coming from julia subprocess in python process. Default: False. + curtailment_14a : dict or None + Configuration for §14a EnWG curtailment of heat pumps and charging points. + Dictionary should contain keys 'apply_curtailment' (bool), 'max_power_mw' + (float), and 'components' (list of component names). + Default: None. """ return powermodels_opf.pm_optimize( self, @@ -913,6 +963,7 @@ def pm_optimize( method=method, warm_start=warm_start, silence_moi=silence_moi, + curtailment_14a=curtailment_14a, ) def to_graph(self): @@ -972,9 +1023,7 @@ def import_generators(self, generator_scenario=None, **kwargs): Other Parameters ---------------- kwargs : - In case you are using new ding0 grids, where the LV is geo-referenced, a - database engine needs to be provided through keyword argument `engine`. - In case you are using old ding0 grids, where the LV is not geo-referenced, + If you are using old ding0 grids, where the LV is not geo-referenced, you can check :func:`edisgo.io.generators_import.oedb_legacy` for possible keyword arguments. @@ -1352,7 +1401,7 @@ def reinforce( """ if copy_grid: - edisgo_obj = copy.deepcopy(self) + edisgo_obj = self.copy() else: edisgo_obj = self @@ -1921,9 +1970,8 @@ def _aggregate_time_series(attribute, groups, naming): def import_electromobility( self, - data_source: str, + data_source: str = "oedb", scenario: str = None, - engine: Engine = None, charging_processes_dir: PurePath | str = None, potential_charging_points_dir: PurePath | str = None, import_electromobility_data_kwds=None, @@ -1965,10 +2013,8 @@ def import_electromobility( * "oedb" Electromobility data is obtained from the `OpenEnergy DataBase - `_. - - This option requires that the parameters `scenario` and `engine` are - provided. + `_ or other eGon-data + databases depending on the provided Engine. * "directory" @@ -1978,9 +2024,6 @@ def import_electromobility( scenario : str Scenario for which to retrieve electromobility data in case `data_source` is set to "oedb". Possible options are "eGon2035" and "eGon100RE". - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. Needs to be provided in case `data_source` is set to - "oedb". charging_processes_dir : str or pathlib.PurePath Directory holding data on charging processes (standing times, charging demand, etc. per vehicle), including metadata, from SimBEV. @@ -2042,7 +2085,7 @@ def import_electromobility( import_electromobility_from_oedb( self, scenario=scenario, - engine=engine, + engine=self.engine, **import_electromobility_data_kwds, ) elif data_source == "directory": @@ -2135,10 +2178,11 @@ def apply_charging_strategy(self, strategy="dumb", **kwargs): """ charging_strategy(self, strategy=strategy, **kwargs) - def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None): + def import_heat_pumps(self, scenario, timeindex=None, import_types=None): """ - Gets heat pump data for specified scenario from oedb and integrates the heat - pumps into the grid. + Gets heat pump data for specified scenario from the OEDB or other eGon-data + databases depending on the provided Engine and integrates the heat pumps into + the grid. Besides heat pump capacity the heat pump's COP and heat demand to be served are as well retrieved. @@ -2193,8 +2237,6 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) scenario : str Scenario for which to retrieve heat pump data. Possible options are 'eGon2035' and 'eGon100RE'. - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. timeindex : :pandas:`pandas.DatetimeIndex` or None Specifies time steps for which to set COP and heat demand data. Leap years can currently not be handled. In case the given @@ -2235,7 +2277,7 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) year = tools.get_year_based_on_scenario(scenario) return self.import_heat_pumps( scenario, - engine, + self.engine, timeindex=pd.date_range(f"1/1/{year}", periods=8760, freq="H"), import_types=import_types, ) @@ -2243,7 +2285,7 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) integrated_heat_pumps = import_heat_pumps_oedb( edisgo_object=self, scenario=scenario, - engine=engine, + engine=self.engine, import_types=import_types, ) if len(integrated_heat_pumps) > 0: @@ -2251,7 +2293,7 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) self, "oedb", heat_pump_names=integrated_heat_pumps, - engine=engine, + engine=self.engine, scenario=scenario, timeindex=timeindex, ) @@ -2259,7 +2301,7 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) self, "oedb", heat_pump_names=integrated_heat_pumps, - engine=engine, + engine=self.engine, timeindex=timeindex, ) @@ -2307,7 +2349,166 @@ def apply_heat_pump_operating_strategy( """ hp_operating_strategy(self, strategy=strategy, heat_pump_names=heat_pump_names) - def import_dsm(self, scenario: str, engine: Engine, timeindex=None): + def apply_curtailment_14a_enwg( + self, + components: list[str] | None = None, + max_power_kw: float = 4.2, + components_type: str | None = None, + ) -> dict[str, float]: + """ + Apply §14a EnWG curtailment to heat pumps and/or charging points. + + §14a EnWG (Netzausbaugebiet) allows network operators to temporarily curtail + controllable consumption devices (especially heat pumps and charging points) + to a maximum power of 4.2 kW instead of performing grid expansion. + This can be used as a cost-effective alternative to grid reinforcement measures. + + The curtailment is applied to the existing time series data in + :attr:`~.network.timeseries.TimeSeries.loads_active_power` and + :attr:`~.network.timeseries.TimeSeries.loads_reactive_power`. + + Parameters + ---------- + components : list of str or None + List of component names (heat pumps and/or charging points) to apply + curtailment to. If None, all heat pumps and charging points in the network + are curtailed. Default: None. + max_power_kw : float + Maximum allowed power in kW after curtailment. According to §14a EnWG, + this is typically 4.2 kW. Default: 4.2. + components_type : str or None + Type of components to curtail. Can be 'heat_pump', 'charging_point', + or None. If None, both heat pumps and charging points are considered. + Only used if `components` is None. Default: None. + + Returns + ------- + dict of str to float + Dictionary with component names as keys and the curtailed energy + (in MWh) as values. This shows how much energy was curtailed for + each component. + + Notes + ----- + The curtailment is applied by limiting the active power time series + to the specified maximum power. The reactive power is adjusted + proportionally based on the power factor. + + The function returns information about curtailed energy which can be + used for cost-benefit analysis when comparing grid expansion costs + with curtailment compensation. + + Examples + -------- + >>> # Apply curtailment to all heat pumps and charging points + >>> curtailed = edisgo.apply_curtailment_14a_enwg() + >>> + >>> # Apply curtailment only to heat pumps + >>> curtailed = edisgo.apply_curtailment_14a_enwg( + ... components_type='heat_pump' + ... ) + >>> + >>> # Apply curtailment to specific components with different limit + >>> curtailed = edisgo.apply_curtailment_14a_enwg( + ... components=['Heat_pump_LVGrid_1_1', 'ChargingPoint_MVGrid_1_home_1'], + ... max_power_kw=3.0 + ... ) + + """ + return apply_curtailment_14a( + self, + components=components, + max_power_kw=max_power_kw, + components_type=components_type, + ) + + def check_curtailment_14a_effect( + self, + components: list[str], + max_power_kw: float = 4.2, + ) -> dict[str, float]: + """ + Check the effect of §14a EnWG curtailment without applying it. + + This function analyzes what the effect of curtailing specified components + according to §14a EnWG would be, without modifying the time series data. + This is useful for planning and cost-benefit analysis. + + Parameters + ---------- + components : list of str + List of component names (heat pumps and/or charging points) to check + curtailment effect for. + max_power_kw : float + Maximum allowed power in kW after curtailment. Default: 4.2. + + Returns + ------- + dict of str to float + Dictionary with the following keys: + - 'total_curtailed_energy_mwh': Total energy that would be curtailed + - 'max_simultaneous_curtailment_mw': Maximum simultaneous curtailed power + - 'avg_curtailed_power_mw': Average curtailed power across all time steps + - 'hours_with_curtailment': Number of hours with active curtailment + + Examples + -------- + >>> # Check effect before applying + >>> effect = edisgo.check_curtailment_14a_effect( + ... components=['Heat_pump_LVGrid_1_1'] + ... ) + >>> print(f"Would curtail {effect['total_curtailed_energy_mwh']:.2f} MWh") + + """ + return check_curtailment_effect( + self, + components=components, + max_power_kw=max_power_kw, + ) + + def identify_components_for_curtailment_14a( + self, + critical_components: list[str] | None = None, + curtailment_priority: str = "p_set", + ) -> list[str]: + """ + Identify which components should be curtailed according to §14a EnWG. + + This function can be used as part of the grid reinforcement optimization + to identify which components should be curtailed instead of performing + grid expansion. + + Parameters + ---------- + critical_components : list of str or None + List of components that are causing grid issues (overloading or + voltage violations). If None, all curtailable components are considered. + Default: None. + curtailment_priority : str + Defines how to prioritize components for curtailment. Options: + 'p_set' (largest nominal power first), 'random', 'grid_level' + (start with LV, then MV). Default: 'p_set'. + + Returns + ------- + list of str + Sorted list of component names to curtail, ordered by priority. + + Examples + -------- + >>> # Identify components to curtail, prioritizing largest ones + >>> components = edisgo.identify_components_for_curtailment_14a( + ... curtailment_priority='p_set' + ... ) + + """ + return identify_components_for_curtailment( + self, + critical_components=critical_components, + curtailment_priority=curtailment_priority, + ) + + def import_dsm(self, scenario: str, timeindex=None): """ Gets industrial and CTS DSM profiles from the `OpenEnergy DataBase `_. @@ -2326,8 +2527,6 @@ def import_dsm(self, scenario: str, engine: Engine, timeindex=None): scenario : str Scenario for which to retrieve DSM data. Possible options are 'eGon2035' and 'eGon100RE'. - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. timeindex : :pandas:`pandas.DatetimeIndex` or None Specifies time steps for which to get data. Leap years can currently not be handled. In case the given timeindex contains a leap year, the data will be @@ -2340,7 +2539,7 @@ def import_dsm(self, scenario: str, engine: Engine, timeindex=None): """ dsm_profiles = dsm_import.oedb( - edisgo_obj=self, scenario=scenario, engine=engine, timeindex=timeindex + edisgo_obj=self, scenario=scenario, engine=self.engine, timeindex=timeindex ) self.dsm.p_min = dsm_profiles["p_min"] self.dsm.p_max = dsm_profiles["p_max"] @@ -2350,7 +2549,6 @@ def import_dsm(self, scenario: str, engine: Engine, timeindex=None): def import_home_batteries( self, scenario: str, - engine: Engine, ): """ Gets home battery data for specified scenario and integrates the batteries into @@ -2361,7 +2559,8 @@ def import_home_batteries( between two scenarios: 'eGon2035' and 'eGon100RE'. The data is retrieved from the - `open energy platform `_. + `open energy platform `_ or other eGon-data + databases depending on the given Engine. The batteries are integrated into the grid (added to :attr:`~.network.topology.Topology.storage_units_df`) based on their building @@ -2378,14 +2577,12 @@ def import_home_batteries( scenario : str Scenario for which to retrieve home battery data. Possible options are 'eGon2035' and 'eGon100RE'. - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. """ home_batteries_oedb( edisgo_obj=self, scenario=scenario, - engine=engine, + engine=self.engine, ) def plot_mv_grid_topology(self, technologies=False, **kwargs): @@ -2412,6 +2609,7 @@ def plot_mv_grid_topology(self, technologies=False, **kwargs): xlim=kwargs.get("xlim", None), ylim=kwargs.get("ylim", None), title=kwargs.get("title", ""), + **kwargs, ) def plot_mv_voltages(self, **kwargs): @@ -3135,7 +3333,7 @@ def spatial_complexity_reduction( """ if copy_edisgo is True: - edisgo_obj = copy.deepcopy(self) + edisgo_obj = self.copy() else: edisgo_obj = self busmap_df, linemap_df = spatial_complexity_reduction( @@ -3349,6 +3547,44 @@ def resample_timeseries( self.heat_pump.resample_timeseries(method=method, freq=freq) self.overlying_grid.resample(method=method, freq=freq) + def copy(self, deep=True): + """ + Returns a copy of the object, with an option for a deep copy. + + The SQLAlchemy engine is excluded from the copying process and restored + afterward. + + Parameters + ---------- + deep : bool + If True, performs a deep copy; otherwise, performs a shallow copy. + + Returns + --------- + :class:`~.EDisGo` + Copied EDisGo object. + + """ + tmp_engine = ( + getattr(self, "engine", None) + if isinstance(getattr(self, "engine", None), Engine) + else None + ) + + if tmp_engine: + logging.info("Temporarily removing the SQLAlchemy engine before copying.") + self.engine = self.config._engine = None + + cpy = copy.deepcopy(self) if deep else copy.copy(self) + + if tmp_engine: + logging.info("Restoring the SQLAlchemy engine after copying.") + self.engine = self.config._engine = cpy.engine = cpy.config._engine = ( + tmp_engine + ) + + return cpy + def import_edisgo_from_pickle(filename, path=""): """ diff --git a/edisgo/flex_opt/__init__.py b/edisgo/flex_opt/__init__.py index e69de29bb..f0d981009 100644 --- a/edisgo/flex_opt/__init__.py +++ b/edisgo/flex_opt/__init__.py @@ -0,0 +1,21 @@ +""" +Flexibility and optimization modules for eDisGo. + +This module contains functions for grid flexibility measures, including: +- Charging strategies for electric vehicles +- Heat pump operation strategies +- Battery storage operation +- Grid reinforcement and optimization +- §14a EnWG curtailment for controllable consumers +""" + +__all__ = [ + "charging_strategies", + "heat_pump_operation", + "battery_storage_operation", + "reinforce_grid", + "curtailment_14a", + "check_tech_constraints", + "costs", + "q_control", +] diff --git a/edisgo/flex_opt/battery_storage_operation.py b/edisgo/flex_opt/battery_storage_operation.py index 64447a8fd..086dec1f8 100644 --- a/edisgo/flex_opt/battery_storage_operation.py +++ b/edisgo/flex_opt/battery_storage_operation.py @@ -164,6 +164,41 @@ def apply_reference_operation( if storage_units_names is None: storage_units_names = edisgo_obj.topology.storage_units_df.index + if ( + edisgo_obj.topology.storage_units_df.loc[ + storage_units_names, "efficiency_store" + ] + .isna() + .all() + ): + logger.warning( + "The efficiency of storage units charge is not specified in the " + "storage_units_df. By default, it is set to 95%. To change this behavior, " + "first set the 'efficiency_store' parameter in topology.storage_units_df." + ) + + edisgo_obj.topology.storage_units_df.loc[ + storage_units_names, "efficiency_store" + ] = 0.95 + + if ( + edisgo_obj.topology.storage_units_df.loc[ + storage_units_names, "efficiency_dispatch" + ] + .isna() + .all() + ): + logger.warning( + "The efficiency of storage units discharge is not specified in the " + "storage_units_df. By default, it is set to 95%. To change this behavior, " + "first set the 'efficiency_dispatch' parameter in " + "topology.storage_units_df." + ) + + edisgo_obj.topology.storage_units_df.loc[ + storage_units_names, "efficiency_dispatch" + ] = 0.95 + storage_units = edisgo_obj.topology.storage_units_df.loc[storage_units_names] soe_df = pd.DataFrame(index=edisgo_obj.timeseries.timeindex) diff --git a/edisgo/flex_opt/costs.py b/edisgo/flex_opt/costs.py index 7cbe359b1..d36fc4893 100644 --- a/edisgo/flex_opt/costs.py +++ b/edisgo/flex_opt/costs.py @@ -162,7 +162,7 @@ def _get_line_costs(lines_added): ]["quantity"].to_frame() lines_added_unique = lines_added.index.unique() lines_added = ( - lines_added.groupby(axis=0, level=0) + lines_added.groupby(level=0) .sum() .loc[lines_added_unique, ["quantity"]] ) diff --git a/edisgo/flex_opt/curtailment_14a.py b/edisgo/flex_opt/curtailment_14a.py new file mode 100644 index 000000000..e0afaa284 --- /dev/null +++ b/edisgo/flex_opt/curtailment_14a.py @@ -0,0 +1,424 @@ +""" +This module implements curtailment according to §14a EnWG (Netzausbaugebiet). + +§14a EnWG allows network operators to temporarily curtail controllable consumption +devices (especially heat pumps and charging points) to a MINIMUM power of 4.2 kW. + +IMPORTANT: This means devices can operate ABOVE 4.2 kW normally, and the operator +can reduce them DOWN TO (but not below) 4.2 kW. + +The functions in this module are primarily for worst-case analysis and testing. +For realistic §14a implementation in grid optimization, use the OPF with the +curtailment_14a parameter in edisgo.optimize() or edisgo.reinforce(). + +""" + +from __future__ import annotations + +import logging + +from typing import TYPE_CHECKING + +import pandas as pd + +if TYPE_CHECKING: + from edisgo import EDisGo + +logger = logging.getLogger(__name__) + + +def apply_curtailment_14a( + edisgo_obj: EDisGo, + components: list[str] | None = None, + max_power_kw: float = 4.2, + components_type: str | None = None, +) -> dict[str, pd.Series]: + """ + Apply §14a EnWG curtailment to heat pumps and/or charging points. + + WARNING: This function applies PERMANENT curtailment to time series data, + limiting all power values to a maximum of 4.2 kW. This is a WORST-CASE + scenario and NOT how §14a is typically used in practice! + + For realistic §14a implementation, use the OPF with curtailment_14a parameter: + >>> edisgo.optimize(curtailment_14a={'apply_curtailment': True}) + + §14a EnWG allows network operators to curtail devices DOWN TO a minimum of + 4.2 kW (not TO a maximum of 4.2 kW). Devices normally operate at their + requested power, and curtailment to 4.2 kW is only applied when needed. + + This function simulates a permanent worst-case where ALL power values are + limited to 4.2 kW, which is useful for comparison but not realistic. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + The eDisGo object containing the network and time series data. + components : list of str or None + List of component names to apply curtailment to. If None, all + heat pumps and charging points in the network are curtailed. + Default: None. + max_power_kw : float + Maximum allowed power in kW after curtailment. According to §14a EnWG, + this is typically 4.2 kW. Default: 4.2. + components_type : str or None + Type of components to curtail. Can be 'heat_pump', 'charging_point', + or None. If None, both heat pumps and charging points are considered. + Only used if `components` is None. Default: None. + + Returns + ------- + dict of str to :pandas:`pandas.Series` + Dictionary with component names as keys and the curtailed energy + (in MWh) as values. This shows how much energy was curtailed for + each component. + + Notes + ----- + The curtailment is applied by limiting ALL active power time series values + to the specified maximum power. The reactive power is adjusted + proportionally based on the power factor. + + This function PERMANENTLY modifies time series data. For a non-destructive + analysis, use check_curtailment_14a_effect() instead. + + For realistic §14a optimization (where curtailment only happens when needed + to avoid grid issues), use the OPF: + >>> edisgo.optimize(curtailment_14a={'apply_curtailment': True}) + + """ + max_power_mw = max_power_kw / 1000.0 # Convert kW to MW + + # Get components to curtail + if components is None: + components = _get_curtailable_components(edisgo_obj, components_type) + else: + # Validate that provided components exist + all_loads = edisgo_obj.topology.loads_df.index.tolist() + invalid_components = [c for c in components if c not in all_loads] + if invalid_components: + raise ValueError( + f"The following components do not exist in the network: " + f"{invalid_components}" + ) + + if not components: + logger.warning( + "No components found for §14a curtailment. No curtailment applied." + ) + return {} + + logger.info( + f"Applying §14a EnWG curtailment to {len(components)} components " + f"with max power {max_power_kw} kW." + ) + + curtailed_energy = {} + + # Get time series data + ts_active = edisgo_obj.timeseries.loads_active_power + ts_reactive = edisgo_obj.timeseries.loads_reactive_power + + for component in components: + if component not in ts_active.columns: + logger.warning( + f"Component {component} has no active power time series. Skipping." + ) + continue + + # Get original time series + original_active = ts_active[component].copy() + + # Apply curtailment + curtailed_active = original_active.clip(upper=max_power_mw) + + # Calculate curtailed energy (difference between original and curtailed) + time_delta = ( + edisgo_obj.timeseries.timeindex[1] - edisgo_obj.timeseries.timeindex[0] + ).total_seconds() / 3600.0 # in hours + energy_curtailed = ((original_active - curtailed_active) * time_delta).sum() + curtailed_energy[component] = energy_curtailed + + # Update active power time series + ts_active[component] = curtailed_active + + # Adjust reactive power proportionally if it exists + if component in ts_reactive.columns: + original_reactive = ts_reactive[component].copy() + # Calculate ratio of curtailed to original power (avoid division by zero) + ratio = pd.Series(1.0, index=original_active.index) + non_zero_mask = original_active != 0 + ratio[non_zero_mask] = ( + curtailed_active[non_zero_mask] / original_active[non_zero_mask] + ) + # Apply same ratio to reactive power + ts_reactive[component] = original_reactive * ratio + + logger.info( + f"§14a curtailment applied. Total curtailed energy: " + f"{sum(curtailed_energy.values()):.2f} MWh across {len(curtailed_energy)} " + f"components." + ) + + return curtailed_energy + + +def _get_curtailable_components( + edisgo_obj: EDisGo, components_type: str | None = None +) -> list[str]: + """ + Get list of components that can be curtailed according to §14a EnWG. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + The eDisGo object containing the network topology. + components_type : str or None + Type of components to get. Can be 'heat_pump', 'charging_point', + or None (both types). Default: None. + + Returns + ------- + list of str + List of component names that can be curtailed. + + """ + loads_df = edisgo_obj.topology.loads_df + + if components_type == "heat_pump": + curtailable = loads_df[loads_df.type == "heat_pump"].index.tolist() + elif components_type == "charging_point": + curtailable = loads_df[loads_df.type == "charging_point"].index.tolist() + elif components_type is None or components_type == "both": + # Get both heat pumps and charging points + curtailable = loads_df[ + loads_df.type.isin(["heat_pump", "charging_point"]) + ].index.tolist() + else: + raise ValueError( + f"Invalid components_type '{components_type}'. Must be " + f"'heat_pump', 'charging_point', 'both', or None." + ) + + return curtailable + + +def identify_components_for_curtailment( + edisgo_obj: EDisGo, + critical_components: list[str] | None = None, + curtailment_priority: str = "p_set", +) -> list[str]: + """ + Identify which components should be curtailed to avoid grid reinforcement. + + This function can be used as part of the grid reinforcement optimization + to identify which components should be curtailed according to §14a EnWG + instead of performing grid expansion. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + The eDisGo object containing the network and analysis results. + critical_components : list of str or None + List of components that are causing grid issues (overloading or + voltage violations). If None, all curtailable components are considered. + Default: None. + curtailment_priority : str + Defines how to prioritize components for curtailment. Options: + 'p_set' (largest nominal power first), 'random', 'grid_level' + (start with LV, then MV). Default: 'p_set'. + + Returns + ------- + list of str + Sorted list of component names to curtail, ordered by priority. + + """ + # Get all curtailable components + all_curtailable = _get_curtailable_components(edisgo_obj, components_type=None) + + if critical_components is not None: + # Filter to only include critical components that are curtailable + components = [c for c in critical_components if c in all_curtailable] + else: + components = all_curtailable + + if not components: + return [] + + # Apply prioritization + loads_df = edisgo_obj.topology.loads_df.loc[components] + + if curtailment_priority == "p_set": + # Prioritize components with highest nominal power + sorted_components = loads_df.sort_values( + "p_set", ascending=False + ).index.tolist() + elif curtailment_priority == "random": + # Random order + sorted_components = loads_df.sample(frac=1).index.tolist() + elif curtailment_priority == "grid_level": + # Prioritize based on grid level (LV first, then MV) + buses_df = edisgo_obj.topology.buses_df + loads_with_grid = loads_df.copy() + loads_with_grid["v_nom"] = loads_with_grid["bus"].map(buses_df["v_nom"]) + # Sort by voltage level (ascending = LV first) + sorted_components = loads_with_grid.sort_values("v_nom").index.tolist() + else: + raise ValueError( + f"Invalid curtailment_priority '{curtailment_priority}'. " + f"Must be 'p_set', 'random', or 'grid_level'." + ) + + return sorted_components + + +def check_curtailment_effect( + edisgo_obj: EDisGo, + components: list[str], + max_power_kw: float = 4.2, +) -> dict[str, float]: + """ + Check the effect of curtailing components without actually applying it. + + This function analyzes what the effect of curtailing specified components + would be, without modifying the time series data. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + The eDisGo object containing the network and time series data. + components : list of str + List of component names to check curtailment effect for. + max_power_kw : float + Maximum allowed power in kW after curtailment. Default: 4.2. + + Returns + ------- + dict of str to float + Dictionary with the following keys: + - 'total_curtailed_energy_mwh': Total energy that would be curtailed + - 'max_simultaneous_curtailment_mw': Maximum simultaneous curtailed power + - 'avg_curtailed_power_mw': Average curtailed power across all time steps + - 'hours_with_curtailment': Number of hours with active curtailment + + """ + max_power_mw = max_power_kw / 1000.0 + + ts_active = edisgo_obj.timeseries.loads_active_power + + # Calculate curtailment for each component + total_curtailment = pd.Series(0.0, index=ts_active.index) + + for component in components: + if component not in ts_active.columns: + continue + + original = ts_active[component] + curtailed = original.clip(upper=max_power_mw) + curtailment = original - curtailed + total_curtailment += curtailment + + # Calculate statistics + time_delta = ( + edisgo_obj.timeseries.timeindex[1] - edisgo_obj.timeseries.timeindex[0] + ).total_seconds() / 3600.0 + + total_curtailed_energy = (total_curtailment * time_delta).sum() + max_simultaneous_curtailment = total_curtailment.max() + avg_curtailed_power = total_curtailment.mean() + hours_with_curtailment = (total_curtailment > 0).sum() * time_delta + + return { + "total_curtailed_energy_mwh": total_curtailed_energy, + "max_simultaneous_curtailment_mw": max_simultaneous_curtailment, + "avg_curtailed_power_mw": avg_curtailed_power, + "hours_with_curtailment": hours_with_curtailment, + } + + +def apply_curtailment_during_reinforcement( + edisgo_obj: EDisGo, + max_power_kw: float | None = None, + components_type: str | None = None, + curtailment_priority: str | None = None, +) -> dict[str, float]: + """ + Apply §14a EnWG curtailment as part of grid reinforcement optimization. + + This function integrates §14a curtailment into the grid reinforcement workflow. + It reads configuration from the config file and applies curtailment to + controllable consumers before grid expansion is considered. + + This function is intended to be called from within the grid reinforcement + process (e.g., from :func:`~.flex_opt.reinforce_grid.reinforce_grid`). + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + The eDisGo object containing the network and time series data. + max_power_kw : float or None + Maximum allowed power in kW after curtailment. If None, the value from + the config is used. Default: None. + components_type : str or None + Type of components to curtail ('heat_pump', 'charging_point', 'both'). + If None, the value from the config is used. Default: None. + curtailment_priority : str or None + Curtailment priority strategy. If None, the value from the config is used. + Default: None. + + Returns + ------- + dict of str to float + Dictionary with component names as keys and the curtailed energy + (in MWh) as values. + + Notes + ----- + This function should only be called if curtailment is enabled in the config + (check `config['curtailment_14a_enwg']['enable_curtailment']`). + + """ + # Check if curtailment is enabled + config = edisgo_obj.config + if not config["curtailment_14a_enwg"]["enable_curtailment"]: + logger.debug("§14a curtailment is disabled in config.") + return {} + + # Read config values if not provided + if max_power_kw is None: + max_power_kw = float(config["curtailment_14a_enwg"]["max_power_kw"]) + + if components_type is None: + components_type = config["curtailment_14a_enwg"]["components_type"] + if components_type == "both": + components_type = None + + if curtailment_priority is None: + curtailment_priority = config["curtailment_14a_enwg"]["curtailment_priority"] + + # Identify components to curtail + components = identify_components_for_curtailment( + edisgo_obj, + critical_components=None, # Consider all components + curtailment_priority=curtailment_priority, + ) + + if not components: + logger.debug("No components available for §14a curtailment.") + return {} + + # Apply curtailment + logger.debug( + f"Applying §14a EnWG curtailment during grid reinforcement to " + f"{len(components)} components." + ) + + curtailed_energy = apply_curtailment_14a( + edisgo_obj, + components=components, + max_power_kw=max_power_kw, + components_type=components_type, + ) + + return curtailed_energy diff --git a/edisgo/flex_opt/reinforce_grid.py b/edisgo/flex_opt/reinforce_grid.py index e56eb58b4..c52fb3192 100644 --- a/edisgo/flex_opt/reinforce_grid.py +++ b/edisgo/flex_opt/reinforce_grid.py @@ -11,6 +11,7 @@ from edisgo.flex_opt import check_tech_constraints as checks from edisgo.flex_opt import exceptions, reinforce_measures from edisgo.flex_opt.costs import grid_expansion_costs +from edisgo.flex_opt.curtailment_14a import apply_curtailment_during_reinforcement from edisgo.flex_opt.reinforce_measures import separate_lv_grid from edisgo.tools import tools from edisgo.tools.temporal_complexity_reduction import get_most_critical_time_steps @@ -88,6 +89,10 @@ def reinforce_grid( This is used in case worst-case grid reinforcement is conducted in order to reinforce MV/LV stations for LV worst-cases. Default: False. + skip_curtailment_14a : bool + If True, §14a EnWG curtailment is not applied even if enabled in the config. + This can be used to compare scenarios with and without curtailment. + Default: False. num_steps_loading : int In case `reduced_analysis` is set to True, this parameter can be used to specify the number of most critical overloading events to consider. @@ -203,6 +208,36 @@ def reinforce_grid( scale_timeseries=scale_timeseries, ) + # CHECK IF §14a EnWG CURTAILMENT SHOULD BE APPLIED + # This allows curtailing heat pumps and charging points to 4.2 kW + # instead of performing grid expansion + # curtailment_applied = False + if edisgo.config["curtailment_14a_enwg"]["enable_curtailment"] and not kwargs.get( + "skip_curtailment_14a", False + ): + logger.info("==> Checking if §14a EnWG curtailment can be applied.") + try: + curtailed_energy = apply_curtailment_during_reinforcement(edisgo) + if curtailed_energy: + logger.info( + f"==> §14a curtailment applied to {len(curtailed_energy)} " + f"components. Total curtailed energy: " + f"{sum(curtailed_energy.values()):.2f} MWh" + ) + # Re-run power flow analysis with curtailed time series + edisgo.analyze( + mode=analyze_mode, + timesteps=timesteps_pfa, + lv_grid_id=lv_grid_id, + scale_timeseries=scale_timeseries, + ) + else: + logger.info("==> No components available for §14a curtailment.") + except Exception as e: + logger.warning( + f"==> §14a curtailment failed: {e}. Proceeding with grid expansion." + ) + # REINFORCE OVERLOADED TRANSFORMERS AND LINES logger.debug("==> Check station load.") overloaded_mv_station = ( @@ -519,7 +554,8 @@ def reinforce_grid( ) raise exceptions.MaximumIterationError( "Over-voltage issues for the following nodes in LV grids " - f"could not be solved: {crit_nodes}" + f"could not be solved within {max_while_iterations} iterations: " + f"{crit_nodes}" ) else: logger.info( diff --git a/edisgo/io/db.py b/edisgo/io/db.py index f4ed7bb9a..5b46f2d9f 100644 --- a/edisgo/io/db.py +++ b/edisgo/io/db.py @@ -178,7 +178,7 @@ def engine( """ - if not ssh: + if path is None: # Github Actions KHs token if "TOEP_TOKEN_KH" in os.environ: token = os.environ["TOEP_TOKEN_KH"] @@ -227,7 +227,8 @@ def engine( ) cred = credentials(path=path) - local_port = ssh_tunnel(cred) + + local_port = ssh_tunnel(cred) if ssh else int(cred["--database-port"]) return create_engine( f"postgresql+psycopg2://{cred['POSTGRES_USER']}:" diff --git a/edisgo/io/ding0_import.py b/edisgo/io/ding0_import.py index 326553111..ffdf740df 100644 --- a/edisgo/io/ding0_import.py +++ b/edisgo/io/ding0_import.py @@ -116,9 +116,9 @@ def sort_hvmv_transformer_buses(transformers_df): columns={"r": "r_pu", "x": "x_pu"} ) ) - edisgo_obj.topology.switches_df = pd.read_csv( - os.path.join(path, "switches.csv"), index_col=[0] - ) + # edisgo_obj.topology.switches_df = pd.read_csv( + # os.path.join(path, "switches.csv"), index_col=[0] + # ) edisgo_obj.topology.grid_district = { "population": grid.mv_grid_district_population, diff --git a/edisgo/io/electromobility_import.py b/edisgo/io/electromobility_import.py index 83dc68af4..d248530a6 100644 --- a/edisgo/io/electromobility_import.py +++ b/edisgo/io/electromobility_import.py @@ -15,6 +15,7 @@ from sklearn import preprocessing from sqlalchemy.engine.base import Engine +from edisgo.io.db import engine as egon_engine from edisgo.io.db import get_srid_of_db_table, session_scope_egon_data from edisgo.tools.config import Config @@ -845,7 +846,8 @@ def distribute_private_charging_demand(edisgo_obj): designated_charging_point_capacity_df = pd.DataFrame( index=user_centric_weights_df.index, columns=["designated_charging_point_capacity"], - data=0, + data=0.0, + dtype=float, ) for destination in private_charging_df.destination.sort_values().unique(): @@ -985,7 +987,8 @@ def distribute_public_charging_demand(edisgo_obj, **kwargs): designated_charging_point_capacity_df = pd.DataFrame( index=grid_and_user_centric_weights_df.index, columns=["designated_charging_point_capacity"], - data=0, + data=0.0, + dtype=float, ) columns = [ @@ -1077,11 +1080,11 @@ def distribute_public_charging_demand(edisgo_obj, **kwargs): idx, "charging_point_id" ] = charging_point_id - available_charging_points_df.loc[ - charging_point_id - ] = edisgo_obj.electromobility.charging_processes_df.loc[ - idx, available_charging_points_df.columns - ].tolist() + available_charging_points_df.loc[charging_point_id] = ( + edisgo_obj.electromobility.charging_processes_df.loc[ + idx, available_charging_points_df.columns + ].tolist() + ) designated_charging_point_capacity_df.at[ charging_park_id, "designated_charging_point_capacity" @@ -1312,6 +1315,9 @@ def charging_processes_from_oedb( more information. """ + if not engine: + engine = egon_engine() + config = Config() egon_ev_mv_grid_district, egon_ev_trip = config.import_tables_from_oep( engine, ["egon_ev_mv_grid_district", "egon_ev_trip"], "demand" diff --git a/edisgo/io/generators_import.py b/edisgo/io/generators_import.py index cd4868c70..28ca1ab9a 100755 --- a/edisgo/io/generators_import.py +++ b/edisgo/io/generators_import.py @@ -341,12 +341,14 @@ def _validate_sample_geno_location(): generators_conv_mv = _import_conv_generators(session) generators_res_mv, generators_res_lv = _import_res_generators(session) - generators_mv = pd.concat( - [ - generators_conv_mv, - generators_res_mv, - ] - ) + # Filter out empty DataFrames before concatenation to avoid FutureWarning + dfs_to_concat = [ + df for df in [generators_conv_mv, generators_res_mv] if not df.empty + ] + if dfs_to_concat: + generators_mv = pd.concat(dfs_to_concat) + else: + generators_mv = pd.DataFrame() # validate that imported generators are located inside the grid district _validate_sample_geno_location() @@ -465,14 +467,20 @@ def _check_mv_generator_geom(generator_data): cap_diff_threshold = 10**-4 # get all imported generators - imported_gens = pd.concat( - [imported_generators_lv, imported_generators_mv], sort=True - ) + # Filter out empty DataFrames before concatenation to avoid FutureWarning + dfs_to_concat = [ + df for df in [imported_generators_lv, imported_generators_mv] if not df.empty + ] + if dfs_to_concat: + imported_gens = pd.concat(dfs_to_concat, sort=True) + else: + imported_gens = pd.DataFrame() logger.debug(f"{len(imported_gens)} generators imported.") - # get existing generators and append ID column - existing_gens = edisgo_object.topology.generators_df + # get existing generators and append ID column (make copy to avoid + # SettingWithCopyWarning) + existing_gens = edisgo_object.topology.generators_df.copy() existing_gens["id"] = list( map(lambda _: int(_.split("_")[-1]), existing_gens.index) ) @@ -1052,15 +1060,15 @@ def _integrate_pv_rooftop(edisgo_object, pv_rooftop_df): suffixes=("_old", ""), ).set_index("gen_name") # add building id - edisgo_object.topology.generators_df.loc[ - gens_existing.index, "building_id" - ] = gens_existing.building_id + edisgo_object.topology.generators_df.loc[gens_existing.index, "building_id"] = ( + gens_existing.building_id + ) # update plants where capacity decreased gens_decreased_cap = gens_existing.query("p_nom < p_nom_old") if len(gens_decreased_cap) > 0: - edisgo_object.topology.generators_df.loc[ - gens_decreased_cap.index, "p_nom" - ] = gens_decreased_cap.p_nom + edisgo_object.topology.generators_df.loc[gens_decreased_cap.index, "p_nom"] = ( + gens_decreased_cap.p_nom + ) # update plants where capacity increased gens_increased_cap = gens_existing.query("p_nom > p_nom_old") for gen in gens_increased_cap.index: @@ -1073,9 +1081,9 @@ def _integrate_pv_rooftop(edisgo_object, pv_rooftop_df): if voltage_level_new >= voltage_level_old: # simply update p_nom if plant doesn't need to be connected to higher # voltage level - edisgo_object.topology.generators_df.at[ - gen, "p_nom" - ] = gens_increased_cap.at[gen, "p_nom"] + edisgo_object.topology.generators_df.at[gen, "p_nom"] = ( + gens_increased_cap.at[gen, "p_nom"] + ) else: # if plant needs to be connected to higher voltage level, remove existing # plant and integrate new component based on geolocation @@ -1183,10 +1191,10 @@ def _integrate_new_pv_rooftop_to_buildings(edisgo_object, pv_rooftop_df): # add voltage level for gen in pv_rooftop_df.index: - pv_rooftop_df.at[ - gen, "voltage_level" - ] = determine_grid_integration_voltage_level( - edisgo_object, pv_rooftop_df.at[gen, "p_nom"] + pv_rooftop_df.at[gen, "voltage_level"] = ( + determine_grid_integration_voltage_level( + edisgo_object, pv_rooftop_df.at[gen, "p_nom"] + ) ) # check for duplicated generator names and choose random name for duplicates @@ -1404,9 +1412,9 @@ def _integrate_new_power_plant(edisgo_object, comp_data): if voltage_level_new >= voltage_level_old: # simply update p_nom if plant doesn't need to be connected to higher # voltage level - edisgo_object.topology.generators_df.at[ - gen, "p_nom" - ] = gens_increased_cap.at[gen, "p_nom"] + edisgo_object.topology.generators_df.at[gen, "p_nom"] = ( + gens_increased_cap.at[gen, "p_nom"] + ) else: # if plant needs to be connected to higher voltage level, remove # existing plant and integrate new component based on geolocation diff --git a/edisgo/io/heat_pump_import.py b/edisgo/io/heat_pump_import.py index f57622911..6218e5a8b 100644 --- a/edisgo/io/heat_pump_import.py +++ b/edisgo/io/heat_pump_import.py @@ -327,7 +327,7 @@ def _get_individual_heat_pump_capacity(): "boundaries", ) egon_etrago_bus, egon_etrago_link = config.import_tables_from_oep( - engine, ["egon_etrago_bus", "egon_etrago_link"], "supply" + engine, ["egon_etrago_bus", "egon_etrago_link"], "grid" ) building_ids = edisgo_object.topology.loads_df.building_id.unique() diff --git a/edisgo/io/powermodels_io.py b/edisgo/io/powermodels_io.py index 541e01ffc..dea05faac 100644 --- a/edisgo/io/powermodels_io.py +++ b/edisgo/io/powermodels_io.py @@ -31,6 +31,7 @@ def to_powermodels( flexible_loads=None, flexible_storage_units=None, opf_version=1, + curtailment_14a=None, ): """ Convert eDisGo representation of the network topology and timeseries to @@ -58,6 +59,12 @@ def to_powermodels( Version of optimization models to choose from. Must be one of [1, 2, 3, 4]. For more information see :func:`edisgo.opf.powermodels_opf.pm_optimize`. Default: 1. + curtailment_14a : dict or None + Dictionary with §14a EnWG curtailment settings. If provided, curtailment + constraints will be added to the OPF. Dictionary should contain: + - 'max_power_mw': Maximum power in MW (e.g., 0.0042 for 4.2 kW) + - 'components': List of component names to curtail (CPs and/or HPs) + Default: None (no curtailment). Returns ------- @@ -209,6 +216,17 @@ def to_powermodels( opf_version = 2 pm["opf_version"] = opf_version + + # Add §14a EnWG curtailment settings if provided + if curtailment_14a is not None: + logger.info("Adding §14a EnWG curtailment constraints to PowerModels dict.") + pm["curtailment_14a"] = { + "max_power_mw": curtailment_14a.get("max_power_mw", 0.0042), + "components": curtailment_14a.get("components", []), + } + else: + pm["curtailment_14a"] = None + logger.info( "Transforming components timeseries into PowerModels dictionary format." ) @@ -250,10 +268,10 @@ def from_powermodels( Base value of apparent power for per unit system. Default: 1 MVA. """ - if type(pm_results) == str: + if isinstance(pm_results, str): with open(pm_results) as f: pm = json.loads(json.load(f)) - elif type(pm_results) == dict: + elif isinstance(pm_results, dict): pm = pm_results else: raise ValueError( @@ -363,9 +381,11 @@ def from_powermodels( for flex in df2.columns: abs_error = abs(df2[flex].values - hv_flex_dict[flex]) rel_error = [ - abs_error[i] / hv_flex_dict[flex][i] - if ((abs_error > 0.01)[i] & (hv_flex_dict[flex][i] != 0)) - else 0 + ( + abs_error[i] / hv_flex_dict[flex][i] + if ((abs_error > 0.01)[i] & (hv_flex_dict[flex][i] != 0)) + else 0 + ) for i in range(len(abs_error)) ] df2[flex] = rel_error @@ -478,6 +498,70 @@ def from_powermodels( edisgo_object.opf_results.grid_slacks_t.hp_load_shedding = df elif var == "phps2": edisgo_object.opf_results.grid_slacks_t.hp_operation_slack = df + + # Extract and log §14a EnWG curtailment results + try: + cp_curtailment_14a = _result_df( + pm, + "electromobility", + "pcp_14a_curt", + timesteps, + edisgo_object.timeseries.timeindex, + s_base, + ) + # Save §14a curtailment to opf_results + edisgo_object.opf_results.curtailment_14a_cp = cp_curtailment_14a + + # Log §14a curtailment statistics for charging points + if not cp_curtailment_14a.empty: + total_cp_curt = cp_curtailment_14a.sum().sum() + if total_cp_curt > 0: + logger.info( + f"§14a Charging Point Curtailment: {total_cp_curt:.4f} MWh total across " + f"{len(cp_curtailment_14a.columns)} components" + ) + # Find components with curtailment + cp_with_curt = cp_curtailment_14a.sum()[cp_curtailment_14a.sum() > 0] + if not cp_with_curt.empty: + logger.info( + f" Components curtailed: {', '.join(cp_with_curt.index.tolist())}" + ) + for comp in cp_with_curt.index: + logger.info( + f" {comp}: {cp_with_curt[comp]:.4f} MWh " + f"(max: {cp_curtailment_14a[comp].max():.4f} MW)" + ) + else: + logger.info("§14a Charging Point Curtailment: NOT used (0 MWh)") + except (KeyError, IndexError) as e: + logger.debug(f"No §14a charging point curtailment data found: {e}") + edisgo_object.opf_results.curtailment_14a_cp = pd.DataFrame() + + try: + hp_curtailment_14a = _result_df( + pm, + "heatpumps", + "php_14a_curt", + timesteps, + edisgo_object.timeseries.timeindex, + s_base, + ) + # Save §14a curtailment to opf_results + edisgo_object.opf_results.curtailment_14a_hp = hp_curtailment_14a + + # Log §14a curtailment statistics for heat pumps + if not hp_curtailment_14a.empty: + total_hp_curt = hp_curtailment_14a.sum().sum() + if total_hp_curt > 0: + logger.info( + f"§14a Heat Pump Curtailment: {total_hp_curt:.4f} MWh total across " + f"{len(hp_curtailment_14a.columns)} components" + ) + else: + logger.info("§14a Heat Pump Curtailment: NOT used (0 MWh)") + except (KeyError, IndexError) as e: + logger.debug(f"No §14a heat pump curtailment data found: {e}") + edisgo_object.opf_results.curtailment_14a_hp = pd.DataFrame() # save line flows and currents to edisgo object for variable in ["pf", "qf", "ccm"]: @@ -664,19 +748,19 @@ def _build_gen(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): idx_bus = _mapping( psa_net, edisgo_obj, - gen.bus[gen_i], + gen.bus.iloc[gen_i], flexible_storage_units=flexible_storage_units, ) pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "generator") q = [ - sign * np.tan(np.arccos(pf)) * gen.p_nom[gen_i], - sign * np.tan(np.arccos(pf)) * gen.p_nom_min[gen_i], + sign * np.tan(np.arccos(pf)) * gen.p_nom.iloc[gen_i], + sign * np.tan(np.arccos(pf)) * gen.p_nom_min.iloc[gen_i], ] pm[text][str(gen_i + 1)] = { - "pg": psa_net.generators_t.p_set[gen.index[gen_i]][0] / s_base, - "qg": psa_net.generators_t.q_set[gen.index[gen_i]][0] / s_base, - "pmax": gen.p_nom[gen_i].round(20) / s_base, - "pmin": gen.p_nom_min[gen_i].round(20) / s_base, + "pg": psa_net.generators_t.p_set[gen.index[gen_i]].iloc[0] / s_base, + "qg": psa_net.generators_t.q_set[gen.index[gen_i]].iloc[0] / s_base, + "pmax": gen.p_nom.iloc[gen_i].round(20) / s_base, + "pmin": gen.p_nom_min.iloc[gen_i].round(20) / s_base, "qmax": max(q).round(20) / s_base, "qmin": min(q).round(20) / s_base, "P": 0, @@ -684,7 +768,7 @@ def _build_gen(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): "vg": 1, "pf": pf, "sign": sign, - "mbase": gen.p_nom[gen_i] / s_base, + "mbase": gen.p_nom.iloc[gen_i] / s_base, "gen_bus": idx_bus, "gen_status": 1, "name": gen.index[gen_i], @@ -707,13 +791,13 @@ def _build_gen(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage_unit") p_g = max( [ - psa_net.storage_units_t.p_set[inflexible_storage_units[stor_i]][0], + psa_net.storage_units_t.p_set[inflexible_storage_units[stor_i]].iloc[0], 0.0, ] ) q_g = min( [ - psa_net.storage_units_t.q_set[inflexible_storage_units[stor_i]][0], + psa_net.storage_units_t.q_set[inflexible_storage_units[stor_i]].iloc[0], 0.0, ] ) @@ -793,38 +877,38 @@ def _build_branch(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): idx_f_bus = _mapping( psa_net, edisgo_obj, - branches.bus0[branch_i], + branches.bus0.iloc[branch_i], flexible_storage_units=flexible_storage_units, ) idx_t_bus = _mapping( psa_net, edisgo_obj, - branches.bus1[branch_i], + branches.bus1.iloc[branch_i], flexible_storage_units=flexible_storage_units, ) pm["branch"][str(branch_i + 1)] = { "name": branches.index[branch_i], - "br_r": branches.r_pu[branch_i] * s_base, - "r": branches.r[branch_i], - "br_x": branches.x_pu[branch_i] * s_base, + "br_r": branches.r_pu.iloc[branch_i] * s_base, + "r": branches.r.iloc[branch_i], + "br_x": branches.x_pu.iloc[branch_i] * s_base, "f_bus": idx_f_bus, "t_bus": idx_t_bus, - "g_to": branches.g_pu[branch_i] / 2 * s_base, - "g_fr": branches.g_pu[branch_i] / 2 * s_base, - "b_to": branches.b_pu[branch_i] / 2 * s_base, - "b_fr": branches.b_pu[branch_i] / 2 * s_base, - "shift": shift[branch_i], + "g_to": branches.g_pu.iloc[branch_i] / 2 * s_base, + "g_fr": branches.g_pu.iloc[branch_i] / 2 * s_base, + "b_to": branches.b_pu.iloc[branch_i] / 2 * s_base, + "b_fr": branches.b_pu.iloc[branch_i] / 2 * s_base, + "shift": shift.iloc[branch_i], "br_status": 1.0, - "rate_a": branches.s_nom[branch_i].real / s_base, + "rate_a": branches.s_nom.iloc[branch_i].real / s_base, "rate_b": 250 / s_base, "rate_c": 250 / s_base, "angmin": -np.pi / 6, "angmax": np.pi / 6, - "transformer": bool(transformer[branch_i]), + "transformer": bool(transformer.iloc[branch_i]), "storage": False, - "tap": tap[branch_i], - "length": branches.length.fillna(1)[branch_i].round(20), - "cost": branches.capital_cost[branch_i].round(20), + "tap": tap.iloc[branch_i], + "length": branches.length.fillna(1).iloc[branch_i].round(20), + "cost": branches.capital_cost.iloc[branch_i].round(20), "storage_pf": 0, "index": branch_i + 1, } @@ -912,7 +996,7 @@ def _build_load( idx_bus = _mapping( psa_net, edisgo_obj, - loads_df.bus[load_i], + loads_df.bus.iloc[load_i], flexible_storage_units=flexible_storage_units, ) if ( @@ -938,8 +1022,8 @@ def _build_load( p_d = psa_net.loads_t.p_set[loads_df.index[load_i]] q_d = psa_net.loads_t.q_set[loads_df.index[load_i]] pm["load"][str(load_i + 1)] = { - "pd": p_d[0].round(20) / s_base, - "qd": q_d[0].round(20) / s_base, + "pd": round(p_d.iloc[0], 20) / s_base, + "qd": round(q_d.iloc[0], 20) / s_base, "load_bus": idx_bus, "status": True, "pf": pf, @@ -958,19 +1042,19 @@ def _build_load( pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage_unit") p_d = -min( [ - psa_net.storage_units_t.p_set[inflexible_storage_units[stor_i]][0], + psa_net.storage_units_t.p_set[inflexible_storage_units[stor_i]].iloc[0], np.float64(0.0), ] ) q_d = -max( [ - psa_net.storage_units_t.q_set[inflexible_storage_units[stor_i]][0], + psa_net.storage_units_t.q_set[inflexible_storage_units[stor_i]].iloc[0], np.float64(0.0), ] ) pm["load"][str(stor_i + len(loads_df.index) + 1)] = { - "pd": p_d.round(20) / s_base, - "qd": q_d.round(20) / s_base, + "pd": round(p_d, 20) / s_base, + "qd": round(q_d, 20) / s_base, "load_bus": idx_bus, "status": True, "pf": pf, @@ -1216,20 +1300,20 @@ def _build_heatpump(psa_net, pm, edisgo_obj, s_base, flexible_hps): ) ) for hp_i in np.arange(len(heat_df.index)): - idx_bus = _mapping(psa_net, edisgo_obj, heat_df.bus[hp_i]) + idx_bus = _mapping(psa_net, edisgo_obj, heat_df.bus.iloc[hp_i]) # retrieve power factor and sign from config pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "heat_pump") - q = sign * np.tan(np.arccos(pf)) * heat_df.p_set[hp_i] + q = sign * np.tan(np.arccos(pf)) * heat_df.p_set.iloc[hp_i] p_d = heat_df2[heat_df.index[hp_i]] pm["heatpumps"][str(hp_i + 1)] = { - "pd": p_d[0].round(20) / s_base, # heat demand + "pd": p_d.iloc[0].round(20) / s_base, # heat demand "pf": pf, "sign": sign, "p_min": 0, - "p_max": heat_df.p_set[hp_i].round(20) / s_base, + "p_max": heat_df.p_set.iloc[hp_i].round(20) / s_base, "q_min": min(q, 0).round(20) / s_base, "q_max": max(q, 0).round(20) / s_base, - "cop": hp_cop[heat_df.index[hp_i]][0].round(20), + "cop": hp_cop[heat_df.index[hp_i]].iloc[0].round(20), "hp_bus": idx_bus, "name": heat_df.index[hp_i], "index": hp_i + 1, @@ -1325,7 +1409,7 @@ def _build_heat_storage(psa_net, pm, edisgo_obj, s_base, flexible_hps, opf_versi heat_storage_df = heat_storage_df.loc[flexible_hps] for stor_i in np.arange(len(flexible_hps)): idx_bus = _mapping( - psa_net, edisgo_obj, psa_net.loads.loc[flexible_hps].bus[stor_i] + psa_net, edisgo_obj, psa_net.loads.loc[flexible_hps].bus.iloc[stor_i] ) if ( edisgo_obj.topology.loads_df.loc[heat_storage_df.index[stor_i]].sector @@ -1338,9 +1422,9 @@ def _build_heat_storage(psa_net, pm, edisgo_obj, s_base, flexible_hps, opf_versi "ps": 0, "p_loss": p_loss, # 4% of SOC per day "energy": 0, - "capacity": heat_storage_df.capacity[stor_i].round(20) / s_base, - "charge_efficiency": heat_storage_df.efficiency[stor_i].round(20), - "discharge_efficiency": heat_storage_df.efficiency[stor_i].round(20), + "capacity": heat_storage_df.capacity.iloc[stor_i].round(20) / s_base, + "charge_efficiency": heat_storage_df.efficiency.iloc[stor_i].round(20), + "discharge_efficiency": heat_storage_df.efficiency.iloc[stor_i].round(20), "storage_bus": idx_bus, "name": heat_storage_df.index[stor_i], "soc_initial": ( diff --git a/edisgo/io/pypsa_io.py b/edisgo/io/pypsa_io.py index 023e5fd69..bf2614c72 100755 --- a/edisgo/io/pypsa_io.py +++ b/edisgo/io/pypsa_io.py @@ -355,6 +355,18 @@ def _set_slack(grid): if kwargs.get("use_seed", False) and pypsa_network.mode == "mv": set_seed(edisgo_object, pypsa_network) + # Ensure all time-varying data has float64 dtype to prevent scipy sparse errors + for component_type in ['loads_t', 'generators_t', 'storage_units_t']: + component_t = getattr(pypsa_network, component_type, None) + if component_t is not None: + for attr in ['p', 'q', 'p_set', 'q_set']: + if hasattr(component_t, attr): + df = getattr(component_t, attr) + if df is not None and isinstance(df, pd.DataFrame) and not df.empty: + # Convert all columns to float64 + for col in df.columns: + df[col] = df[col].astype('float64', errors='ignore') + return pypsa_network diff --git a/edisgo/io/storage_import.py b/edisgo/io/storage_import.py index 2ba1716cf..e5031ed24 100644 --- a/edisgo/io/storage_import.py +++ b/edisgo/io/storage_import.py @@ -73,7 +73,13 @@ def home_batteries_oedb( ) batteries_df = pd.read_sql(sql=query.statement, con=engine, index_col=None) - return _home_batteries_grid_integration(edisgo_obj, batteries_df) + names = _home_batteries_grid_integration(edisgo_obj, batteries_df) + + edisgo_obj.topology.storage_units_df.building_id = ( + edisgo_obj.topology.storage_units_df.building_id.astype(int) + ) + + return names def _home_batteries_grid_integration(edisgo_obj, batteries_df): diff --git a/edisgo/network/timeseries.py b/edisgo/network/timeseries.py index 6cf4a7b47..e5230d7ce 100644 --- a/edisgo/network/timeseries.py +++ b/edisgo/network/timeseries.py @@ -14,6 +14,7 @@ from edisgo.io import timeseries_import from edisgo.tools.tools import assign_voltage_level_to_component, resample +from edisgo.io.db import engine as egon_engine if TYPE_CHECKING: from edisgo import EDisGo @@ -1250,6 +1251,10 @@ def predefined_fluctuating_generators_by_technology( are used. """ + if not engine: + engine = egon_engine() + + # in case time series from oedb are used, retrieve oedb time series if isinstance(ts_generators, str) and ts_generators == "oedb": if edisgo_object.legacy_grids is True: @@ -1520,6 +1525,76 @@ def predefined_charging_points_by_use_case( ).T self.add_component_time_series("loads_active_power", ts_scaled) + def active_power_p_max_pu( + self, edisgo_object, ts_generators_p_max_pu, generator_names=None + ): + """ + Set active power feed-in time series for generators using p_max_pu time series. + + This function reads generator-specific p_max_pu time series (normalized to + nominal capacity) and scales them by the nominal power (p_nom) of each + generator to obtain absolute active power time series. + + Parameters + ---------- + edisgo_object : :class:`~.EDisGo` + ts_generators_p_max_pu : :pandas:`pandas.DataFrame` + DataFrame with generator-specific p_max_pu time series normalized to + a nominal capacity of 1. Each column represents a specific generator + and should match the generator names in the network. + Index needs to be a :pandas:`pandas.DatetimeIndex`. + Column names should correspond to generator names in + :attr:`~.network.topology.Topology.generators_df`. + generator_names : list(str), optional + Defines for which generators to set p_max_pu time series. If None, + all generators for which p_max_pu time series are provided in + `ts_generators_p_max_pu` are used. Default: None. + + Notes + ----- + This function is useful when you have generator-specific capacity factors + or availability profiles that differ from technology-wide profiles. + + """ + if not isinstance(ts_generators_p_max_pu, pd.DataFrame): + raise ValueError( + "Parameter 'ts_generators_p_max_pu' must be a pandas DataFrame." + ) + elif ts_generators_p_max_pu.empty: + logger.warning("Provided time series dataframe is empty.") + return + + # set generator_names if None + if generator_names is None: + generator_names = ts_generators_p_max_pu.columns.tolist() + + generator_names = self._check_if_components_exist( + edisgo_object, generator_names, "generators" + ) + + # Filter to only include generators that have time series provided + generators_with_ts = [ + gen for gen in generator_names if gen in ts_generators_p_max_pu.columns + ] + + if not generators_with_ts: + logger.warning( + "None of the specified generators have time series in " + "ts_generators_p_max_pu." + ) + return + + generators_df = edisgo_object.topology.generators_df.loc[generators_with_ts, :] + + # scale time series by nominal power + ts_scaled = generators_df.apply( + lambda x: ts_generators_p_max_pu[x.name] * x.p_nom, + axis=1, + ).T + + if not ts_scaled.empty: + self.add_component_time_series("generators_active_power", ts_scaled) + def fixed_cosphi( self, edisgo_object, @@ -1729,19 +1804,21 @@ def _get_q_sign_and_power_factor_per_component( ], inplace=True, ) - self.time_series_raw.q_control = pd.concat( - [ - self.time_series_raw.q_control, - pd.DataFrame( - index=components_names, - data={ - "type": "fixed_cosphi", - "q_sign": q_sign, - "power_factor": power_factor, - }, - ), - ] + new_data = pd.DataFrame( + index=components_names, + data={ + "type": "fixed_cosphi", + "q_sign": q_sign, + "power_factor": power_factor, + }, ) + frames_to_concat = [ + df for df in [self.time_series_raw.q_control, new_data] if not df.empty + ] + if frames_to_concat: + self.time_series_raw.q_control = pd.concat(frames_to_concat) + elif not new_data.empty: + self.time_series_raw.q_control = new_data return q_sign, power_factor # set reactive power for generators diff --git a/edisgo/network/topology.py b/edisgo/network/topology.py index 1eb0a9272..a75d0d609 100755 --- a/edisgo/network/topology.py +++ b/edisgo/network/topology.py @@ -171,10 +171,10 @@ def _load_equipment_data(config=None): config = {} for voltage_level, eq_list in equipment.items(): for i in eq_list: - config[ - "equipment_{}_parameters_{}".format(voltage_level, i) - ] = "equipment-parameters_{}_{}.csv".format( - voltage_level.upper(), i + config["equipment_{}_parameters_{}".format(voltage_level, i)] = ( + "equipment-parameters_{}_{}.csv".format( + voltage_level.upper(), i + ) ) else: equipment_dir = config["system_dirs"]["equipment_dir"] @@ -1447,9 +1447,7 @@ def _get_line_data(): line_name = "Line_{}_{}".format(bus0, bus1) while line_name in self.lines_df.index: random.seed(a=line_name) - line_name = "Line_{}_{}_{}".format( - bus0, bus1, random.randint(10**8, 10**9) - ) + line_name = "Line_{}_{}_{}".format(bus0, bus1, random.randint(10**8, 10**9)) # check if all necessary data is now available if b is None: @@ -1560,7 +1558,7 @@ def remove_load(self, name): """ if name in self.loads_df.index: bus = self.loads_df.at[name, "bus"] - self._loads_df.drop(name, inplace=True) + self._loads_df = self._loads_df.drop(name) # if no other elements are connected, remove line and bus as well if self._check_bus_for_removal(bus): @@ -1583,7 +1581,7 @@ def remove_generator(self, name): """ if name in self.generators_df.index: bus = self.generators_df.at[name, "bus"] - self._generators_df.drop(name, inplace=True) + self._generators_df = self._generators_df.drop(name) # if no other elements are connected to same bus, remove line # and bus @@ -1610,7 +1608,7 @@ def remove_storage_unit(self, name): # remove storage unit and time series if name in self.storage_units_df.index: bus = self.storage_units_df.at[name, "bus"] - self._storage_units_df.drop(name, inplace=True) + self._storage_units_df = self._storage_units_df.drop(name) # if no other elements are connected, remove line and bus as well if self._check_bus_for_removal(bus): @@ -1723,9 +1721,9 @@ def update_number_of_parallel_lines(self, lines_num_parallel): ) # update number parallel lines - self._lines_df.loc[ - lines_num_parallel.index, "num_parallel" - ] = lines_num_parallel + self._lines_df.loc[lines_num_parallel.index, "num_parallel"] = ( + lines_num_parallel + ) def change_line_type(self, lines, new_line_type): """ @@ -1878,7 +1876,7 @@ def connect_to_mv(self, edisgo_object, comp_data, comp_type="generator"): power = comp_data.pop("p") # create new bus for new component - if type(comp_data["geom"]) != Point: + if not isinstance(comp_data["geom"], Point): geom = wkt_loads(comp_data["geom"]) else: geom = comp_data["geom"] @@ -3197,9 +3195,9 @@ def aggregate_lv_grid_at_station(self, lv_grid_id: int | str) -> None: self.buses_df = self.buses_df[~self.buses_df.index.isin(buses_to_drop)] self.lines_df = self.lines_df[~self.lines_df.index.isin(lines_to_drop)] self.loads_df.loc[self.loads_df.bus.isin(buses_to_drop), "bus"] = station_bus - self.generators_df.loc[ - self.generators_df.bus.isin(buses_to_drop), "bus" - ] = station_bus + self.generators_df.loc[self.generators_df.bus.isin(buses_to_drop), "bus"] = ( + station_bus + ) self.storage_units_df.loc[ self.storage_units_df.bus.isin(buses_to_drop), "bus" ] = station_bus diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_curtailment_14a.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_curtailment_14a.jl new file mode 100644 index 000000000..c26fa3ebb --- /dev/null +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_curtailment_14a.jl @@ -0,0 +1,97 @@ +# §14a EnWG Curtailment Constraints +# Diese Constraints implementieren §14a EnWG Abregelung als Alternative zu Netzausbau +# §14a erlaubt Netzbetreibern die Leistung auf MINIMAL 4.2 kW zu begrenzen + +""" +Add §14a EnWG curtailment constraints for charging points and heat pumps. + +§14a EnWG allows network operators to curtail devices to a MINIMUM of 4.2 kW. +This is implemented as a HARD constraint - devices cannot go below 4.2 kW! + +Correct interpretation: +- Devices normally run at their requested power (can be > 4.2 kW) +- §14a allows curtailment DOWN TO a minimum of 4.2 kW +- Below 4.2 kW is NOT allowed (hard constraint, no slack!) + +Formulation: + pcp >= curtailment_limit (HARD constraint) + pcp_14a_curt >= p_max - pcp (tracking variable for cost calculation) + +Where: +- Device power is bounded: [4.2 kW, p_max] +- If grid issues require < 4.2 kW: other constraints will be violated instead +- This ensures §14a minimum is ALWAYS respected + +Cost structure (hierarchy from cheap to expensive): +1. Normal flexibility (storage, redispatch): Factor 0.6 +2. §14a curtailment: Factor 100 (expensive! last resort before grid violations) +3. Grid violations (voltage/current): Factor 10,000 + +Examples: +- No grid issues: Device runs at p_max (e.g., 11 kW), pcp_14a_curt = 0, no §14a costs +- Grid congestion: Device curtailed to 6 kW, pcp_14a_curt = 5 kW, costs = 100 × 5 = 500 +- Critical: Device at 4.2 kW minimum, pcp_14a_curt = 6.8 kW, costs = 100 × 6.8 = 680 +- Impossible: If even 4.2 kW too much → grid violations occur (cost: 10,000) + +This ensures §14a is used only when normal flexibility is insufficient, +but prevents grid violations when possible. +""" +function constraint_curtailment_14a!(pm::AbstractPowerModel, nw::Int) + # Check if curtailment_14a is defined in the data + curtailment_data = get(PowerModels.ref(pm, nw), :curtailment_14a, nothing) + + if curtailment_data === nothing || curtailment_data == "nothing" + # No curtailment constraints + return + end + + curtailment_limit_mw = get(curtailment_data, "max_power_mw", 0.0042) # 4.2 kW MINIMUM + components = get(curtailment_data, "components", []) + + # Apply HARD minimum power constraint to charging points + for (i, cp) in PowerModels.ref(pm, nw, :electromobility) + if isempty(components) || get(cp, "name", "") in components + pcp = PowerModels.var(pm, nw, :pcp, i) + pcp_14a_curt = PowerModels.var(pm, nw, :pcp_14a_curt, i) + + # Only apply constraint if p_max > curtailment_limit + # (doesn't make sense to enforce 4.2 kW minimum on a 3 kW device) + if cp["p_max"] >= curtailment_limit_mw + # HARD CONSTRAINT: pcp >= 4.2 kW + # No slack variable - this is absolute! + # + # Examples for 11 kW device: + # pcp can be: [4.2, 11.0] kW + # pcp cannot be: < 4.2 kW (infeasible!) + # + # If grid cannot handle 4.2 kW: grid violations will occur + JuMP.@constraint(pm.model, pcp >= curtailment_limit_mw) + + # Track curtailment amount for cost calculation + # pcp_14a_curt measures how much power is curtailed due to §14a + # Example: p_max=11 kW, pcp=6 kW → pcp_14a_curt=5 kW (curtailment amount) + # MUST be equality (==) so negative costs work correctly! + JuMP.@constraint(pm.model, pcp_14a_curt == cp["p_max"] - pcp) + end + end + end + + # Apply HARD minimum power constraint to heat pumps + for (i, hp) in PowerModels.ref(pm, nw, :heatpumps) + if isempty(components) || get(hp, "name", "") in components + php = PowerModels.var(pm, nw, :php, i) + php_14a_curt = PowerModels.var(pm, nw, :php_14a_curt, i) + + # Only apply constraint if p_max > curtailment_limit + if hp["p_max"] >= curtailment_limit_mw + # HARD CONSTRAINT: php >= 4.2 kW (no slack!) + JuMP.@constraint(pm.model, php >= curtailment_limit_mw) + + # Track curtailment amount for cost calculation + # php_14a_curt measures how much power is curtailed due to §14a + # MUST be equality (==) so negative costs work correctly! + JuMP.@constraint(pm.model, php_14a_curt == hp["p_max"] - php) + end + end + end +end diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/objective.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/objective.jl index 5a8470750..c9dc88734 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/core/objective.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/objective.jl @@ -28,7 +28,17 @@ function objective_min_losses_slacks(pm::AbstractBFModelEdisgo) phps = Dict(n => PowerModels.var(pm, n, :phps) for n in nws) phps2 = Dict(n => PowerModels.var(pm, n, :phps2) for n in nws) phss = Dict(n => PowerModels.var(pm, n, :phss) for n in nws) + pcp_14a_curt = Dict(n => PowerModels.var(pm, n, :pcp_14a_curt) for n in nws) + php_14a_curt = Dict(n => PowerModels.var(pm, n, :php_14a_curt) for n in nws) + + # Cost factors: + # - Normal flexibility (storage, redispatch): 0.6 + # - §14a curtailment: 100 (expensive! use as last resort before grid violations) + # - Grid violations (voltage/current): 10,000 (most expensive) + factor_slacks = 0.6 + factor_14a = 100 + return JuMP.@objective(pm.model, Min, (1-factor_slacks) * sum(sum(ccm[n][b] * r[n][b] for (b,i,j) in PowerModels.ref(pm, n, :arcs_from) ) for n in nws) # minimize line losses incl. storage losses + factor_slacks * sum(sum(pgc[n][i] for i in keys(PowerModels.ref(pm,1 , :gen_nd))) for n in nws) # minimize non-dispatchable curtailment @@ -36,6 +46,8 @@ function objective_min_losses_slacks(pm::AbstractBFModelEdisgo) + factor_slacks * sum(sum(pds[n][i] for i in keys(PowerModels.ref(pm,1 , :load))) for n in nws) # minimize load shedding + factor_slacks * sum(sum(pcps[n][i] for i in keys(PowerModels.ref(pm,1 , :electromobility))) for n in nws) # minimize cp load sheddin + factor_slacks * sum(sum(phps[n][i] for i in keys(PowerModels.ref(pm,1 , :heatpumps))) for n in nws) # minimize hp load shedding + + factor_14a * sum(sum(pcp_14a_curt[n][i] for i in keys(PowerModels.ref(pm, 1, :electromobility))) for n in nws) # minimize §14a CP curtailment (expensive!) + + factor_14a * sum(sum(php_14a_curt[n][i] for i in keys(PowerModels.ref(pm, 1, :heatpumps))) for n in nws) # minimize §14a HP curtailment (expensive!) + 1e4 * sum(sum(phss[n][i] + phps2[n][i] for i in keys(PowerModels.ref(pm, 1 , :heatpumps))) for n in nws) ) end @@ -69,12 +81,18 @@ function objective_min_losses_slacks_OG(pm::AbstractBFModelEdisgo) phps2 = Dict(n => PowerModels.var(pm, n, :phps2) for n in nws) phss = Dict(n => PowerModels.var(pm, n, :phss) for n in nws) phvs = Dict(n => PowerModels.var(pm, n, :phvs) for n in nws) + pcp_14a_curt = Dict(n => PowerModels.var(pm, n, :pcp_14a_curt) for n in nws) + php_14a_curt = Dict(n => PowerModels.var(pm, n, :php_14a_curt) for n in nws) + parameters = [r[1][i] for i in keys(r[1])] parameters = parameters[parameters .>0] #factor_hv_slacks = length(nws) * exp10(floor(log10(maximum(parameters)))+2) factor_hv_slacks = exp10(floor(log10(maximum(parameters)))+1) #println(factor_hv_slacks) factor_slacks = 0.6 + factor_14a = 100 + + return JuMP.@objective(pm.model, Min, (1-factor_slacks) * sum(sum(ccm[n][b]*r[n][b] for (b,i,j) in PowerModels.ref(pm, n, :arcs_from)) for n in nws) # minimize line losses + factor_slacks * sum(sum(pgc[n][i] for i in keys(PowerModels.ref(pm,1 , :gen_nd))) for n in nws) # minimize non-dispatchable curtailment @@ -82,6 +100,8 @@ function objective_min_losses_slacks_OG(pm::AbstractBFModelEdisgo) + factor_slacks * sum(sum(pds[n][i] for i in keys(PowerModels.ref(pm,1 , :load))) for n in nws) # minimize load shedding + factor_slacks * sum(sum(pcps[n][i] for i in keys(PowerModels.ref(pm,1 , :electromobility))) for n in nws) # minimize cp load shedding + factor_slacks * sum(sum(phps[n][i] for i in keys(PowerModels.ref(pm, 1 , :heatpumps))) for n in nws) # minimize hp load shedding + + factor_14a * sum(sum(pcp_14a_curt[n][i] for i in keys(PowerModels.ref(pm, 1, :electromobility))) for n in nws) # minimize §14a CP curtailment (expensive!) + + factor_14a * sum(sum(php_14a_curt[n][i] for i in keys(PowerModels.ref(pm, 1, :heatpumps))) for n in nws) # minimize §14a HP curtailment (expensive!) + factor_hv_slacks * sum(sum(phvs[n][i]^2 * flex["count"] for (i, flex) in PowerModels.ref(pm, n, :HV_requirements) if flex["name"]!= "dsm") for n in nws) # + factor_hv_slacks * 1e-1 * sum(sum(phvs[n][i]^2 * flex["count"] for (i, flex) in PowerModels.ref(pm, n, :HV_requirements) if flex["name"]== "dsm") for n in nws) # + 1e4 * sum(sum(phss[n][i] + phps2[n][i] for i in keys(PowerModels.ref(pm, 1 , :heatpumps))) for n in nws) diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl index 5aec7f71c..f87bfef14 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl @@ -485,6 +485,32 @@ function variable_ev_slack(pm::AbstractBFModelEdisgo; nw::Int=nw_id_default, bou report && PowerModels.sol_component_value(pm, nw, :electromobility, :pcps, PowerModels.ids(pm, nw, :electromobility), pcps) end +"Charging point §14a curtailment tracking variable" +function variable_cp_14a_curtailment(pm::AbstractBFModelEdisgo; nw::Int=nw_id_default, bounded::Bool=true, report::Bool=true) + # Upper bound is p_max (maximum possible curtailment when pcp = 0) + # This prevents unbounded maximization with negative cost factors + pcp_14a_curt = PowerModels.var(pm, nw)[:pcp_14a_curt] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :electromobility)], base_name="$(nw)_pcp_14a_curt", + lower_bound = 0.0, + upper_bound = PowerModels.ref(pm, nw, :electromobility, i)["p_max"] + ) + + report && PowerModels.sol_component_value(pm, nw, :electromobility, :pcp_14a_curt, PowerModels.ids(pm, nw, :electromobility), pcp_14a_curt) +end + +"Heat pump §14a curtailment tracking variable" +function variable_hp_14a_curtailment(pm::AbstractBFModelEdisgo; nw::Int=nw_id_default, bounded::Bool=true, report::Bool=true) + # Upper bound is p_max (maximum possible curtailment when php = 0) + # This prevents unbounded maximization with negative cost factors + php_14a_curt = PowerModels.var(pm, nw)[:php_14a_curt] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :heatpumps)], base_name="$(nw)_php_14a_curt", + lower_bound = 0.0, + upper_bound = PowerModels.ref(pm, nw, :heatpumps, i)["p_max"] + ) + + report && PowerModels.sol_component_value(pm, nw, :heatpumps, :php_14a_curt, PowerModels.ids(pm, nw, :heatpumps), php_14a_curt) +end + "slack generator variables" function variable_slack_gen(pm::AbstractBFModelEdisgo; kwargs...) eDisGo_OPF.variable_slack_gen_real(pm; kwargs...) @@ -534,4 +560,9 @@ function variable_slack_HV_requirements_imaginary(pm::AbstractPowerModel; nw::In end +# Note: §14a EnWG slack variables have been removed! +# §14a is now implemented as HARD constraint - no violations allowed. +# If grid cannot handle minimum 4.2 kW, grid violations (voltage/current) occur instead. +# This ensures the 4.2 kW minimum is ALWAYS respected when §14a is active. + "" diff --git a/edisgo/opf/eDisGo_OPF.jl/src/eDisGo_OPF.jl b/edisgo/opf/eDisGo_OPF.jl/src/eDisGo_OPF.jl index 33df80742..bb0bd923b 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/eDisGo_OPF.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/eDisGo_OPF.jl @@ -18,6 +18,7 @@ include("core/types.jl") include("core/base.jl") include("core/constraint.jl") include("core/constraint_template.jl") +include("core/constraint_curtailment_14a.jl") include("core/data.jl") include("core/objective.jl") include("core/solution.jl") diff --git a/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl b/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl index aeaea8d65..5d98ec2aa 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl @@ -31,6 +31,10 @@ function build_mn_opf_bf_flex(pm::AbstractBFModelEdisgo) eDisGo_OPF.variable_dsm_storage_power(pm, nw=n) # Eq. (3.34), (3.35) eDisGo_OPF.variable_slack_gen(pm, nw=n) # keine Bounds für Slack Generator + # §14a tracking variables to measure curtailment amount (for cost calculation) + eDisGo_OPF.variable_cp_14a_curtailment(pm, nw=n) + eDisGo_OPF.variable_hp_14a_curtailment(pm, nw=n) + if PowerModels.ref(pm, 1, :opf_version) in(3, 4) # Nicht Teil der MA eDisGo_OPF.variable_slack_HV_requirements(pm, nw=n) if PowerModels.ref(pm, 1, :opf_version) in(3) @@ -58,6 +62,9 @@ function build_mn_opf_bf_flex(pm::AbstractBFModelEdisgo) eDisGo_OPF.constraint_hp_operation(pm, i, n) # Eq. (3.19) end + # §14a EnWG curtailment constraint + eDisGo_OPF.constraint_curtailment_14a!(pm, n) + end # CONSTRAINTS diff --git a/edisgo/opf/powermodels_opf.py b/edisgo/opf/powermodels_opf.py index 85da160a8..b03cf56ef 100644 --- a/edisgo/opf/powermodels_opf.py +++ b/edisgo/opf/powermodels_opf.py @@ -26,6 +26,7 @@ def pm_optimize( method: str = "soc", warm_start: bool = False, silence_moi: bool = False, + curtailment_14a: Optional[dict] = None, ) -> None: """ Run OPF for edisgo object in julia subprocess and write results of OPF to edisgo @@ -93,6 +94,13 @@ def pm_optimize( hence there will be no logging coming from julia subprocess in python process. Default: False. + curtailment_14a : dict or None + Dictionary with §14a EnWG curtailment settings. If provided, curtailment + constraints will be added to the OPF for charging points and heat pumps. + Dictionary should contain: + - 'max_power_mw': Maximum power in MW (default: 0.0042 for 4.2 kW) + - 'components': List of component names to apply curtailment to + Default: None (no curtailment constraints). save_heat_storage : bool Indicates whether to save results of heat storage variables from the optimization to eDisGo object. @@ -118,6 +126,7 @@ def pm_optimize( flexible_loads=flexible_loads, flexible_storage_units=flexible_storage_units, opf_version=opf_version, + curtailment_14a=curtailment_14a, ) def _convert(o): @@ -163,6 +172,14 @@ def _convert(o): hv_flex_dict=hv_flex_dict, s_base=s_base, ) + # Fix dtypes after writing OPF results to prevent scipy sparse matrix errors + for ts_df_name in ['loads_active_power', 'loads_reactive_power', + 'generators_active_power', 'generators_reactive_power', + 'storage_units_active_power', 'storage_units_reactive_power']: + ts_df = getattr(edisgo_obj.timeseries, ts_df_name, None) + if ts_df is not None and not ts_df.empty: + for col in ts_df.columns: + ts_df[col] = ts_df[col].astype('float64') elif out.rstrip().startswith("Set parameter") or out.rstrip().startswith( "Academic" ): diff --git a/edisgo/tools/config.py b/edisgo/tools/config.py index b4748bf44..a2466eedf 100644 --- a/edisgo/tools/config.py +++ b/edisgo/tools/config.py @@ -132,6 +132,8 @@ class Config: """ def __init__(self, **kwargs): + self._engine = kwargs.get("engine", None) + if not kwargs.get("from_json", False): self._data = self.from_cfg(kwargs.get("config_path", "default")) else: @@ -175,7 +177,7 @@ def _ensure_db_mappings_loaded(self) -> None: def get_database_alias_dictionaries(self) -> tuple[dict[str, str], dict[str, str]]: """ - Retrieves the database alias dictionaries for table and schema mappings. + Retrieves the OEP database alias dictionaries for table and schema mappings. Returns ------- @@ -187,13 +189,13 @@ def get_database_alias_dictionaries(self) -> tuple[dict[str, str], dict[str, str names. """ engine = Engine() - dictionary_schema_name = "data" + dictionary_schema_name = "dataset" dictionary_table = self._get_module_attr( self._get_saio_module(dictionary_schema_name, engine), "edut_00", f"saio.{dictionary_schema_name}", ) - with session_scope_egon_data(engine) as session: + with session_scope_egon_data(self._engine) as session: query = session.query(dictionary_table) dictionary_entries = query.all() name_mapping = { diff --git a/edisgo/tools/plots.py b/edisgo/tools/plots.py index cfc8b4958..bf5d522c6 100644 --- a/edisgo/tools/plots.py +++ b/edisgo/tools/plots.py @@ -391,7 +391,7 @@ def get_color_and_size(connected_components, colors_dict, sizes_dict): else: return colors_dict["else"], sizes_dict["else"] - def nodes_by_technology(buses, edisgo_obj): + def nodes_by_technology(buses, edisgo_obj, sizes_dict=None): bus_sizes = {} bus_colors = {} colors_dict = { @@ -405,17 +405,18 @@ def nodes_by_technology(buses, edisgo_obj): "DisconnectingPoint": "0.75", "else": "orange", } - sizes_dict = { - "BranchTee": 10000, - "GeneratorFluctuating": 100000, - "Generator": 100000, - "Load": 100000, - "LVStation": 50000, - "MVStation": 120000, - "Storage": 100000, - "DisconnectingPoint": 75000, - "else": 200000, - } + if sizes_dict is None: + sizes_dict = { + "BranchTee": 10000, + "GeneratorFluctuating": 100000, + "Generator": 100000, + "Load": 100000, + "LVStation": 50000, + "MVStation": 120000, + "Storage": 100000, + "DisconnectingPoint": 75000, + "else": 200000, + } for bus in buses: connected_components = ( edisgo_obj.topology.get_connected_components_from_bus(bus) @@ -583,7 +584,9 @@ def nodes_by_costs(buses, grid_expansion_costs, edisgo_obj): # bus colors and sizes if node_color == "technology": - bus_sizes, bus_colors = nodes_by_technology(pypsa_plot.buses.index, edisgo_obj) + bus_sizes, bus_colors = nodes_by_technology( + pypsa_plot.buses.index, edisgo_obj, kwargs.get("sizes_dict", None) + ) bus_cmap = None elif node_color == "voltage": bus_sizes, bus_colors = nodes_by_voltage( diff --git a/edisgo/tools/tools.py b/edisgo/tools/tools.py index 66353c55d..053d337d0 100644 --- a/edisgo/tools/tools.py +++ b/edisgo/tools/tools.py @@ -12,6 +12,8 @@ import pandas as pd from sqlalchemy.engine.base import Engine +from edisgo.io.db import engine as egon_engine + from edisgo.flex_opt import exceptions, q_control from edisgo.io.db import session_scope_egon_data, sql_grid_geom, sql_intersects @@ -579,6 +581,7 @@ def assign_voltage_level_to_component(df, buses_df): (either 'mv' or 'lv'). """ + df = df.copy() df["voltage_level"] = df.apply( lambda _: "lv" if buses_df.at[_.bus, "v_nom"] < 1 else "mv", axis=1, @@ -729,6 +732,8 @@ def get_weather_cells_intersecting_with_grid_district( Set with weather cell IDs. """ + if engine is None: + engine = egon_engine() # Download geometries of weather cells sql_geom = sql_grid_geom(edisgo_obj) srid = edisgo_obj.topology.grid_district["srid"] diff --git a/examples/Workshop_LoMa.ipynb b/examples/Workshop_LoMa.ipynb new file mode 100644 index 000000000..3c6467a0a --- /dev/null +++ b/examples/Workshop_LoMa.ipynb @@ -0,0 +1,1155 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# LoMa EDisGo-Workshop 27.2.2025" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "Contents:\n", + "1. Topology Setup\n", + "2. Worst Case Time Series Creation\n", + "3. Grid Investigation\n", + "4. Results\n", + "5. Additional Time Series\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext jupyter_black" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import requests\n", + "import sys\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import networkx as nx\n", + "import pandas as pd\n", + "\n", + "from copy import deepcopy\n", + "from numpy.random import default_rng\n", + "from pathlib import Path\n", + "\n", + "from edisgo import EDisGo\n", + "from edisgo.io.db import engine\n", + "from edisgo.tools.logger import setup_logger\n", + "from edisgo.flex_opt.battery_storage_operation import apply_reference_operation\n", + "from edisgo.network.results import Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# to make the notebook clearer. not recommendable\n", + "import warnings\n", + "\n", + "warnings.filterwarnings(\"ignore\")" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## 1 Topology Setup" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "In this section we load all components into a newly created edisgo object. This includes the lines, buses, transformers, switches, generators, loads, heat pumps and battery storages." + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### Standard components" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Set up a new edisgo object:" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "conf_path and ding0_grid need to be set according to local storage location." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "conf_path = Path.home() / \"Downloads\" / \"egon-data.configuration.yaml\"\n", + "assert conf_path.is_file()\n", + "\n", + "db_engine = engine(path=conf_path, ssh=True)\n", + "\n", + "ding0_grid = Path.home() / \".edisgo\" / \"husum_grids\" / \"35725\"\n", + "assert ding0_grid.is_dir()\n", + "\n", + "edisgo = EDisGo(ding0_grid=ding0_grid, legacy_ding0_grids=False, engine=db_engine)" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "ding0 and edisgo use different assumptions for the grid design and extension, respectively. This may cause that edisgo detects voltage deviations and line overloads. To avoid this the edisgo assumptions should be transferred to the ding0 grid by applying ```reinforce()``` after the grid import." + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "Grids are reinforced for their worst case scenarios. The corresponding time series are created with ```set_time_series_worst_case_analysis()```. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.set_time_series_worst_case_analysis()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.reinforce()" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "### Plot grid topology (MV)" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "The topology can be visualized with the ```plot_mv_grid_topology()```. For ```technologies=True``` the buses sizes and colors are determined to the type and size of the technologies connected to it. \n", + "\n", + "- red: nodes with substation secondary side\n", + "- light blue: nodes distribution substations's primary side\n", + "- green: nodes with fluctuating generators\n", + "- black: nodes with conventional generators\n", + "- grey: disconnecting points\n", + "- dark blue: branch trees" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "# adjust node sizes to make plot clearer\n", + "sizes_dict = {\n", + " \"BranchTee\": 10000,\n", + " \"GeneratorFluctuating\": 100000,\n", + " \"Generator\": 100000,\n", + " \"Load\": 100000,\n", + " \"LVStation\": 50000,\n", + " \"MVStation\": 120000,\n", + " \"Storage\": 100000,\n", + " \"DisconnectingPoint\": 75000,\n", + " \"else\": 200000,\n", + "}\n", + "\n", + "sizes_dict = {k: v / 10 for k, v in sizes_dict.items()}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_grid_topology(technologies=True, sizes_dict=sizes_dict)" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "### Topology-Module Data Structure" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "Let's get familiar with the topology module:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "# generator types\n", + "edisgo.topology.generators_df[[\"p_nom\", \"type\"]].groupby(\"type\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "# load types\n", + "edisgo.topology.loads_df[[\"p_set\", \"type\"]].groupby(\"type\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "# load sectors\n", + "edisgo.topology.loads_df[[\"p_set\", \"sector\"]].groupby(\"sector\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "# amount of lv grids inside the mv grid\n", + "len(list(edisgo.topology.mv_grid.lv_grids))" + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "Total number of lines:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "# overall amount of lines\n", + "len(edisgo.topology.lines_df)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "# amount of lines in one of the lv grids\n", + "len(edisgo.topology.grids[5].lines_df.index)" + ] + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "### Basic components addition and removal" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": [ + "To see how a loaded network can be adapted later on, we add a solar plant to a random bus.\n", + "\n", + "Components can also be added according to their geolocation with the function ```integrate_component_based_on_geolocation()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "Add a generator with the function ```add_component()``` or ```add_generator()```. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "# determine a random bus\n", + "rng = default_rng(1)\n", + "rnd_bus = rng.choice(edisgo.topology.buses_df.index, size=1)[0]\n", + "generator_type = \"solar\"\n", + "\n", + "new_generator = edisgo.add_component(\n", + " comp_type=\"generator\", p_nom=0.01, bus=rnd_bus, generator_type=generator_type\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "34", + "metadata": {}, + "source": [ + "Single components can be removed with ```remove_component()```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.remove_component(comp_type=\"generator\", comp_name=new_generator)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "37", + "metadata": {}, + "source": [ + "### Task: \n", + "Add and remove a 'heat_pump' with the function ```add_component()``` and the function ```remove_component()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "markdown", + "id": "43", + "metadata": {}, + "source": [ + "### Add flexible components to grid " + ] + }, + { + "cell_type": "markdown", + "id": "44", + "metadata": {}, + "source": [ + "For realistic future grids we also add further components like additional generators, home batteries, (charging points) and heat pumps. The components are added according to the scenario \"eGon2035\" and the data from the oedb." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45", + "metadata": {}, + "outputs": [], + "source": [ + "scenario = \"eGon2035\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46", + "metadata": {}, + "outputs": [], + "source": [ + "# copy the edisgo object for later comparisons\n", + "edisgo_orig = edisgo.copy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47", + "metadata": {}, + "outputs": [], + "source": [ + "# clear initial reinfocement results from results module\n", + "edisgo.results = Results(edisgo)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48", + "metadata": {}, + "outputs": [], + "source": [ + "# set timeindex to ensure that correct time series for COP and heat pump heat demand are downloaded\n", + "timeindex = pd.date_range(f\"1/1/{2011} 8:00\", periods=12, freq=\"H\")\n", + "edisgo.set_timeindex(timeindex=timeindex)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49", + "metadata": {}, + "outputs": [], + "source": [ + "# Retry if running into \"Connection reset by peer\" error\n", + "\n", + "edisgo.import_generators(generator_scenario=scenario)\n", + "edisgo.import_home_batteries(scenario=scenario)\n", + "edisgo.import_heat_pumps(scenario=scenario)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50", + "metadata": {}, + "outputs": [], + "source": [ + "# This takes too long for the workshop\n", + "# edisgo_obj.import_dsm(scenario=scenario)\n", + "# edisgo_obj.import_electromobility(\n", + "# data_source=\"oedb\", scenario=scenario\n", + "# )" + ] + }, + { + "cell_type": "markdown", + "id": "51", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the differnet generator types that were installed before and that are installed in the grid now." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "54", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the added solar energy power." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "56", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the amount of storage units added to the grid with a nominal power (p_nom) larger than 0.01." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "58", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the buses of the heat pumps whose application ('sector') is not inidividual_heating." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "60", + "metadata": {}, + "source": [ + "## 2 Worst Case Time Series Creation" + ] + }, + { + "cell_type": "markdown", + "id": "61", + "metadata": {}, + "source": [ + "Create timeseries for the four worst cases MV load case, LV load case, MV feed-in case, LV feed-in case with the function set_time_series_worst_case_analysis().\n", + "\n", + "In conventional grid expansion planning worst-cases, the heavy load flow and the reverse power flow, are used to determine grid expansion needs. eDisGo allows you to analyze these cases separately or together. Choose between the following options:\n", + "\n", + "* **’feed-in_case’** \n", + " \n", + " Feed-in and demand for the worst-case scenario \"reverse power flow\" are generated (e.g. conventional electricity demand is set to 15% of maximum demand for loads connected to the MV grid and 10% for loads connected to the LV grid and feed-in of all generators is set to the nominal power of the generator, except for PV systems where it is by default set to 85% of the nominal power)\n", + "\n", + " \n", + "* **’load_case’**\n", + "\n", + " Feed-in and demand for the worst-case scenario \"heavy load flow\" are generated (e.g. demand of all conventional loads is by default set to maximum demand and feed-in of all generators is set to zero)\n", + "\n", + "\n", + "* **[’feed-in_case’, ’load_case’]**\n", + "\n", + " Both cases are set up.\n", + " \n", + "By default both cases are set up.\n", + "\n", + "Feed-in and demand in the two worst-cases are defined in the [config file 'config_timeseries.cfg'](https://edisgo.readthedocs.io/en/latest/configs.html#config-timeseries) and can be changed by setting different values in the config file. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.set_time_series_worst_case_analysis()" + ] + }, + { + "cell_type": "markdown", + "id": "63", + "metadata": {}, + "source": [ + "The function creates time series for four time steps since both worst cases are defined seperately for the LV and the MV grid with individual simultanerity factors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.timeindex_worst_cases" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65", + "metadata": {}, + "outputs": [], + "source": [ + "# indexing with worst case timeindex\n", + "edisgo.timeseries.loads_active_power.loc[\n", + " edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"]\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "66", + "metadata": {}, + "source": [ + "## 3 Grid Investigation" + ] + }, + { + "cell_type": "markdown", + "id": "67", + "metadata": {}, + "source": [ + "Execute a power flow analysis to determine line overloads and voltage deviations for the MV load case timeseries with the function ```analyze()```:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68", + "metadata": {}, + "outputs": [], + "source": [ + "# power flow analysis\n", + "edisgo.analyze(timesteps=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"])" + ] + }, + { + "cell_type": "markdown", + "id": "69", + "metadata": {}, + "source": [ + "A geoplot with the bus and line colors based on the voltage deviations and line loadings repectively can be created with ```plot_mv_line_loading()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "70", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_line_loading(\n", + " node_color=\"voltage_deviation\",\n", + " timestep=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "71", + "metadata": {}, + "source": [ + "For a better overview of the voltage deviations and line loads in the entire grid, edisgo provides histrogram plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_voltage(binwidth=0.005)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_relative_line_load(binwidth=0.1)" + ] + }, + { + "cell_type": "markdown", + "id": "74", + "metadata": {}, + "source": [ + "## 4 Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75", + "metadata": {}, + "outputs": [], + "source": [ + "# Reinforce the grid\n", + "# mode = \"mvlv\" for a shorter run time. However, grid reinforcement should generally be conducted in mode=\"lv\" (default)\n", + "# since the majority of the reinforcement costs is caused in the lv grid part, especially for high load grids (much EV charging demand and low PV capacity)\n", + "# The lv mode is currently not applicable for the Husum grid. The newly added generators are concentrated in one LV grid. The reinforcement\n", + "# very long or cannot be resolved. This issue will be fixed soon. A possible workarounf for running the reinfocrement anyway is to remove the generators for the overloaded LV grid.\n", + "edisgo.reinforce(mode=\"mvlv\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_line_loading(\n", + " node_color=\"voltage_deviation\",\n", + " timestep=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "77", + "metadata": {}, + "outputs": [], + "source": [ + "# power flow analysis to retrieve all bus voltages and line flows\n", + "edisgo.analyze(timesteps=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_voltage(binwidth=0.005)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_relative_line_load(binwidth=0.1)" + ] + }, + { + "cell_type": "markdown", + "id": "80", + "metadata": {}, + "source": [ + "The module ```results```holds the outputs of the reinforcement" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "81", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo_orig.results.equipment_changes" + ] + }, + { + "cell_type": "markdown", + "id": "82", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the total costs for the grid reinforcement. The costs for each added component are stored in the data frame ```edisgo.results.grid_expansion_costs```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "84", + "metadata": {}, + "source": [ + "## 5 Additional Time Series\n", + "\n", + "Besides setting worst case scenarios and the corresponding time series, component time series can also be set with the function ```predefined()```. Either standard profiles for different component types are loaded from a data base or type- (for generators) and sectorwise (for loads) time series can be determined manually and passed to the function. \n", + "\n", + "The function ```set_time_series_manual()``` can be used to set individual time series for components. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85", + "metadata": {}, + "outputs": [], + "source": [ + "# determine interval time series are set for\n", + "# timeindex has to be set again to desired time interval because it was overwritten by set_time_series_worst_case()\n", + "timeindex = pd.date_range(f\"1/1/{2011} 8:00\", periods=12, freq=\"H\")\n", + "edisgo.set_timeindex(timeindex=timeindex)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "86", + "metadata": {}, + "outputs": [], + "source": [ + "# check which load sectors are included in the Husum grid\n", + "set(edisgo.topology.loads_df[\"sector\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87", + "metadata": {}, + "outputs": [], + "source": [ + "# constant load for all time steps for all load sectors\n", + "timeseries_load = pd.DataFrame(\n", + " {\n", + " \"industrial\": [0.0001] * len(timeindex),\n", + " \"cts\": [0.0002] * len(timeindex),\n", + " \"residential\": [0.0002] * len(timeindex),\n", + " \"district_heating_resistive_heater\": [0.0002] * len(timeindex),\n", + " \"individual_heating\": [0.0002] * len(timeindex),\n", + " },\n", + " index=timeindex,\n", + ")\n", + "\n", + "# annual_consumption of loads is not set in Husum data set\n", + "edisgo.topology.loads_df[\"annual_consumption\"] = 700 * edisgo.topology.loads_df[\"p_set\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88", + "metadata": {}, + "outputs": [], + "source": [ + "# check which generator types are included into the grid\n", + "set(edisgo.topology.generators_df[\"type\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89", + "metadata": {}, + "outputs": [], + "source": [ + "# constant feed-in for dispatchable generators\n", + "timeseries_generation_dispatchable = pd.DataFrame(\n", + " {\n", + " \"biomass\": [1] * len(timeindex),\n", + " \"gas\": [1] * len(timeindex),\n", + " \"other\": [1] * len(timeindex),\n", + " },\n", + " index=timeindex,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "90", + "metadata": {}, + "outputs": [], + "source": [ + "# determine fluctuating generators, for which generator-type time series are loaded from a data base\n", + "fluctuating_generators = edisgo.topology.generators_df[\n", + " edisgo.topology.generators_df[\"type\"].isin([\"solar\", \"wind\"])\n", + "].index" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91", + "metadata": {}, + "outputs": [], + "source": [ + "# set active power time series for loads and generators\n", + "edisgo.set_time_series_active_power_predefined(\n", + " fluctuating_generators=fluctuating_generators,\n", + " fluctuating_generators_ts=\"oedb\",\n", + " scenario=scenario,\n", + " timeindex=edisgo.timeseries.timeindex,\n", + " conventional_loads_ts=timeseries_load,\n", + " dispatchable_generators_ts=timeseries_generation_dispatchable,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "92", + "metadata": {}, + "source": [ + "## Task\n", + "Plot the time series for three solar generators and gas power plants in individual plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "94", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "95", + "metadata": {}, + "source": [ + "## Task\n", + "Plot the time series of three conventional loads and of three heat pumps in individual plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "96", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98", + "metadata": {}, + "outputs": [], + "source": [ + "# set heat pump time series\n", + "# set_time_series_active_power_predefined does not consider heat demand\n", + "edisgo.apply_heat_pump_operating_strategy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_heat_pumps = edisgo.timeseries.loads_active_power.loc[\n", + " :, edisgo.topology.loads_df[\"type\"] == \"heat_pump\"\n", + "]\n", + "timeseries_heat_pumps.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "100", + "metadata": {}, + "outputs": [], + "source": [ + "# set battery storage time series (not included in set_time_series_active_power_predefined())\n", + "apply_reference_operation(edisgo)\n", + "# returns soe" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "101", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.storage_units_active_power.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "102", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.storage_units_active_power.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "103", + "metadata": {}, + "outputs": [], + "source": [ + "# set reactive power time series\n", + "edisgo.set_time_series_reactive_power_control()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "104", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.generators_reactive_power.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "105", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/Workshop_LoMa_solutions.ipynb b/examples/Workshop_LoMa_solutions.ipynb new file mode 100644 index 000000000..bfd619095 --- /dev/null +++ b/examples/Workshop_LoMa_solutions.ipynb @@ -0,0 +1,1189 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# LoMa EDisGo-Workshop 27.2.2025" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "Contents:\n", + "1. Topology Setup\n", + "2. Worst Case Time Series Creation\n", + "3. Grid Investigation\n", + "4. Results\n", + "5. Additional Time Series\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext jupyter_black" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import requests\n", + "import sys\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import networkx as nx\n", + "import pandas as pd\n", + "\n", + "from copy import deepcopy\n", + "from numpy.random import default_rng\n", + "from pathlib import Path\n", + "\n", + "from edisgo import EDisGo\n", + "from edisgo.io.db import engine\n", + "from edisgo.tools.logger import setup_logger\n", + "from edisgo.flex_opt.battery_storage_operation import apply_reference_operation\n", + "from edisgo.network.results import Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# to make the notebook clearer. not recommendable\n", + "import warnings\n", + "\n", + "warnings.filterwarnings(\"ignore\")" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## 1 Topology Setup" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "In this section we load all components into a newly created edisgo object. This includes the lines, buses, transformers, switches, generators, loads, heat pumps and battery storages." + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### Standard components" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Set up a new edisgo object:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "conf_path = Path.home() / \"Downloads\" / \"egon-data.configuration.yaml\"\n", + "assert conf_path.is_file()\n", + "\n", + "db_engine = engine(path=conf_path, ssh=True)\n", + "\n", + "ding0_grid = Path.home() / \".edisgo\" / \"husum_grids\" / \"35725\"\n", + "assert ding0_grid.is_dir()\n", + "\n", + "edisgo = EDisGo(ding0_grid=ding0_grid, legacy_ding0_grids=False, engine=db_engine)" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "ding0 and edisgo use different assumptions for the grid design and extension, respectively. This may cause that edisgo detects voltage deviations and line overloads. To avoid this the edisgo assumptions should be transferred to the ding0 grid by applying ```reinforce()``` after the grid import." + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "Grids are reinforced for their worst case scenarios. The corresponding time series are created with ```set_time_series_worst_case_analysis()```. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.set_time_series_worst_case_analysis()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.reinforce()" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "### Plot grid topology (MV)" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "The topology can be visualized with the ```plot_mv_grid_topology()```. For ```technologies=True``` the buses sizes and colors are determined to the type and size of the technologies connected to it. \n", + "\n", + "- red: nodes with substation secondary side\n", + "- light blue: nodes distribution substations's primary side\n", + "- green: nodes with fluctuating generators\n", + "- black: nodes with conventional generators\n", + "- grey: disconnecting points\n", + "- dark blue: branch trees" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "# adjust node sizes to make plot clearer\n", + "sizes_dict = {\n", + " \"BranchTee\": 10000,\n", + " \"GeneratorFluctuating\": 100000,\n", + " \"Generator\": 100000,\n", + " \"Load\": 100000,\n", + " \"LVStation\": 50000,\n", + " \"MVStation\": 120000,\n", + " \"Storage\": 100000,\n", + " \"DisconnectingPoint\": 75000,\n", + " \"else\": 200000,\n", + "}\n", + "\n", + "sizes_dict = {k: v / 10 for k, v in sizes_dict.items()}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_grid_topology(technologies=True, sizes_dict=sizes_dict)" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "### Topology-Module Data Structure" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "Let's get familiar with the topology module:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "# generator types\n", + "edisgo.topology.generators_df[[\"p_nom\", \"type\"]].groupby(\"type\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "# load types\n", + "edisgo.topology.loads_df[[\"p_set\", \"type\"]].groupby(\"type\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "# load sectors\n", + "edisgo.topology.loads_df[[\"p_set\", \"sector\"]].groupby(\"sector\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "# amount of lv grids inside the mv grid\n", + "len(list(edisgo.topology.mv_grid.lv_grids))" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "Total number of lines:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "# overall amount of lines\n", + "len(edisgo.topology.lines_df)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "# amount of lines in one of the lv grids\n", + "len(edisgo.topology.grids[5].lines_df.index)" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "### Basic components addition and removal" + ] + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "To see how a loaded network can be adapted later on, we add a solar plant to a random bus.\n", + "\n", + "Components can also be added according to their geolocation with the function ```integrate_component_based_on_geolocation()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "30", + "metadata": {}, + "source": [ + "Add a generator with the function ```add_component()``` or ```add_generator()```. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], + "source": [ + "# determine a random bus\n", + "rng = default_rng(1)\n", + "rnd_bus = rng.choice(edisgo.topology.buses_df.index, size=1)[0]\n", + "generator_type = \"solar\"\n", + "\n", + "new_generator = edisgo.add_component(\n", + " comp_type=\"generator\", p_nom=0.01, bus=rnd_bus, generator_type=generator_type\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "Single components can be removed with ```remove_component()```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.remove_component(comp_type=\"generator\", comp_name=new_generator)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "36", + "metadata": {}, + "source": [ + "### Task: \n", + "Add and remove a 'heat_pump' with the function ```add_component()``` and the function ```remove_component()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "new_load = edisgo.add_component(\n", + " comp_type=\"load\", p_set=0.01, bus=rnd_bus, type=\"heat_pump\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.remove_component(comp_type=\"load\", comp_name=new_load)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "markdown", + "id": "42", + "metadata": {}, + "source": [ + "### Add flexible components to grid " + ] + }, + { + "cell_type": "markdown", + "id": "43", + "metadata": {}, + "source": [ + "For realistic future grids we also add further components like additional generators, home batteries, (charging points) and heat pumps. The components are added according to the scenario \"eGon2035\" and the data from the oedb." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44", + "metadata": {}, + "outputs": [], + "source": [ + "scenario = \"eGon2035\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45", + "metadata": {}, + "outputs": [], + "source": [ + "# copy the edisgo object for later comparisons\n", + "edisgo_orig = edisgo.copy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46", + "metadata": {}, + "outputs": [], + "source": [ + "# clear initial reinfocement results from results module\n", + "edisgo.results = Results(edisgo)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47", + "metadata": {}, + "outputs": [], + "source": [ + "# set timeindex to ensure that correct time series for COP and heat pump heat demand are downloaded\n", + "timeindex = pd.date_range(f\"1/1/{2011} 8:00\", periods=12, freq=\"H\")\n", + "edisgo.set_timeindex(timeindex=timeindex)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48", + "metadata": {}, + "outputs": [], + "source": [ + "# Retry if running into \"Connection reset by peer\" error\n", + "\n", + "edisgo.import_generators(generator_scenario=scenario)\n", + "edisgo.import_home_batteries(scenario=scenario)\n", + "edisgo.import_heat_pumps(scenario=scenario)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49", + "metadata": {}, + "outputs": [], + "source": [ + "# This takes too long for the workshop\n", + "# edisgo_obj.import_dsm(scenario=scenario)\n", + "# edisgo_obj.import_electromobility(\n", + "# data_source=\"oedb\", scenario=scenario\n", + "# )" + ] + }, + { + "cell_type": "markdown", + "id": "50", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the differnet generator types that were installed before and that are installed in the grid now." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "51", + "metadata": {}, + "outputs": [], + "source": [ + "set(edisgo.topology.generators_df[\"type\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52", + "metadata": {}, + "outputs": [], + "source": [ + "set(edisgo_orig.topology.generators_df[\"type\"])" + ] + }, + { + "cell_type": "markdown", + "id": "53", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the added solar energy power." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54", + "metadata": {}, + "outputs": [], + "source": [ + "solar_power_new = edisgo.topology.generators_df[\n", + " edisgo.topology.generators_df[\"type\"] == \"solar\"\n", + "][\"p_nom\"].sum()\n", + "\n", + "solar_power_old = edisgo_orig.topology.generators_df[\n", + " edisgo_orig.topology.generators_df[\"type\"] == \"solar\"\n", + "][\"p_nom\"].sum()\n", + "\n", + "solar_power_new - solar_power_old" + ] + }, + { + "cell_type": "markdown", + "id": "55", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the amount of storage units added to the grid with a nominal power (p_nom) larger than 0.01." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56", + "metadata": {}, + "outputs": [], + "source": [ + "sum(edisgo.topology.storage_units_df[\"p_nom\"] > 0.01)" + ] + }, + { + "cell_type": "markdown", + "id": "57", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the buses of the heat pumps whose application ('sector') is not inidividual_heating." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df.loc[\n", + " (edisgo.topology.loads_df[\"type\"] == \"heat_pump\")\n", + " & (edisgo.topology.loads_df[\"sector\"] != \"individual_heating\"),\n", + " \"bus\",\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "59", + "metadata": {}, + "source": [ + "## 2 Worst Case Time Series Creation" + ] + }, + { + "cell_type": "markdown", + "id": "60", + "metadata": {}, + "source": [ + "Create timeseries for the four worst cases MV load case, LV load case, MV feed-in case, LV feed-in case with the function set_time_series_worst_case_analysis().\n", + "\n", + "In conventional grid expansion planning worst-cases, the heavy load flow and the reverse power flow, are used to determine grid expansion needs. eDisGo allows you to analyze these cases separately or together. Choose between the following options:\n", + "\n", + "* **’feed-in_case’** \n", + " \n", + " Feed-in and demand for the worst-case scenario \"reverse power flow\" are generated (e.g. conventional electricity demand is set to 15% of maximum demand for loads connected to the MV grid and 10% for loads connected to the LV grid and feed-in of all generators is set to the nominal power of the generator, except for PV systems where it is by default set to 85% of the nominal power)\n", + "\n", + " \n", + "* **’load_case’**\n", + "\n", + " Feed-in and demand for the worst-case scenario \"heavy load flow\" are generated (e.g. demand of all conventional loads is by default set to maximum demand and feed-in of all generators is set to zero)\n", + "\n", + "\n", + "* **[’feed-in_case’, ’load_case’]**\n", + "\n", + " Both cases are set up.\n", + " \n", + "By default both cases are set up.\n", + "\n", + "Feed-in and demand in the two worst-cases are defined in the [config file 'config_timeseries.cfg'](https://edisgo.readthedocs.io/en/latest/configs.html#config-timeseries) and can be changed by setting different values in the config file. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.set_time_series_worst_case_analysis()" + ] + }, + { + "cell_type": "markdown", + "id": "62", + "metadata": {}, + "source": [ + "The function creates time series for four time steps since both worst cases are defined seperately for the LV and the MV grid with individual simultanerity factors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.timeindex_worst_cases" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64", + "metadata": {}, + "outputs": [], + "source": [ + "# indexing with worst case timeindex\n", + "edisgo.timeseries.loads_active_power.loc[\n", + " edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"]\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "65", + "metadata": {}, + "source": [ + "## 3 Grid Investigation" + ] + }, + { + "cell_type": "markdown", + "id": "66", + "metadata": {}, + "source": [ + "Execute a power flow analysis to determine line overloads and voltage deviations for the MV load case timeseries with the function ```analyze()```:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "67", + "metadata": {}, + "outputs": [], + "source": [ + "# power flow analysis\n", + "edisgo.analyze(timesteps=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"])" + ] + }, + { + "cell_type": "markdown", + "id": "68", + "metadata": {}, + "source": [ + "A geoplot with the bus and line colors based on the voltage deviations and line loadings repectively can be created with ```plot_mv_line_loading()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "69", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_line_loading(\n", + " node_color=\"voltage_deviation\",\n", + " timestep=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "70", + "metadata": {}, + "source": [ + "For a better overview of the voltage deviations and line loads in the entire grid, edisgo provides histrogram plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "71", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_voltage(binwidth=0.005)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_relative_line_load(binwidth=0.1)" + ] + }, + { + "cell_type": "markdown", + "id": "73", + "metadata": {}, + "source": [ + "## 4 Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74", + "metadata": {}, + "outputs": [], + "source": [ + "# Reinforce the grid\n", + "# mode = \"mvlv\" for a shorter run time. However, grid reinforcement should generally be conducted in mode=\"lv\" (default)\n", + "# since the majority of the reinforcement costs is caused in the lv grid part, especially for high load grids (much EV charging demand and low PV capacity)\n", + "# The lv mode is currently not applicable for the Husum grid. The newly added generators are concentrated in one LV grid. The reinforcement\n", + "# very long or cannot be resolved. This issue will be fixed soon. A possible workarounf for running the reinfocrement anyway is to remove the generators for the overloaded LV grid.\n", + "edisgo.reinforce(mode=\"mvlv\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_line_loading(\n", + " node_color=\"voltage_deviation\",\n", + " timestep=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76", + "metadata": {}, + "outputs": [], + "source": [ + "# power flow analysis to retrieve all bus voltages and line flows\n", + "edisgo.analyze(timesteps=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "77", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_voltage(binwidth=0.005)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_relative_line_load(binwidth=0.1)" + ] + }, + { + "cell_type": "markdown", + "id": "79", + "metadata": {}, + "source": [ + "The module ```results```holds the outputs of the reinforcement" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo_orig.results.equipment_changes" + ] + }, + { + "cell_type": "markdown", + "id": "81", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the total costs for the grid reinforcement. The costs for each added component are stored in the data frame ```edisgo.results.grid_expansion_costs```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "82", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.results.grid_expansion_costs[\"total_costs\"].sum()" + ] + }, + { + "cell_type": "markdown", + "id": "83", + "metadata": {}, + "source": [ + "## 5 Additional Time Series\n", + "\n", + "Besides setting worst case scenarios and the corresponding time series, component time series can also be set with the function ```predefined()```. Either standard profiles for different component types are loaded from a data base or type- (for generators) and sectorwise (for loads) time series can be determined manually and passed to the function. \n", + "\n", + "The function ```set_time_series_manual()``` can be used to set individual time series for components. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "84", + "metadata": {}, + "outputs": [], + "source": [ + "# determine interval time series are set for\n", + "# timeindex has to be set again to desired time interval because it was overwritten by set_time_series_worst_case()\n", + "timeindex = pd.date_range(f\"1/1/{2011} 8:00\", periods=12, freq=\"H\")\n", + "edisgo.set_timeindex(timeindex=timeindex)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85", + "metadata": {}, + "outputs": [], + "source": [ + "# check which load sectors are included in the Husum grid\n", + "set(edisgo.topology.loads_df[\"sector\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "86", + "metadata": {}, + "outputs": [], + "source": [ + "# constant load for all time steps for all load types\n", + "timeseries_load = pd.DataFrame(\n", + " {\n", + " \"industrial\": [0.0001] * len(timeindex),\n", + " \"cts\": [0.0002] * len(timeindex),\n", + " \"residential\": [0.0002] * len(timeindex),\n", + " \"district_heating_resistive_heater\": [0.0002] * len(timeindex),\n", + " \"individual_heating\": [0.0002] * len(timeindex),\n", + " },\n", + " index=timeindex,\n", + ")\n", + "\n", + "# annual_consumption of loads is not set in Husum data set\n", + "edisgo.topology.loads_df[\"annual_consumption\"] = 700 * edisgo.topology.loads_df[\"p_set\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87", + "metadata": {}, + "outputs": [], + "source": [ + "# check which generator types are included into the grid\n", + "set(edisgo.topology.generators_df[\"type\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88", + "metadata": {}, + "outputs": [], + "source": [ + "# constant feed-in for dispatchable generators\n", + "timeseries_generation_dispatchable = pd.DataFrame(\n", + " {\n", + " \"biomass\": [1] * len(timeindex),\n", + " \"gas\": [1] * len(timeindex),\n", + " \"other\": [1] * len(timeindex),\n", + " },\n", + " index=timeindex,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89", + "metadata": {}, + "outputs": [], + "source": [ + "# determine fluctuating generators, for which generator-type time series are loaded from a data base\n", + "fluctuating_generators = edisgo.topology.generators_df[\n", + " edisgo.topology.generators_df[\"type\"].isin([\"solar\", \"wind\"])\n", + "].index" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "90", + "metadata": {}, + "outputs": [], + "source": [ + "# set active power time series for loads and generators\n", + "edisgo.set_time_series_active_power_predefined(\n", + " fluctuating_generators=fluctuating_generators,\n", + " fluctuating_generators_ts=\"oedb\",\n", + " scenario=scenario,\n", + " timeindex=edisgo.timeseries.timeindex,\n", + " conventional_loads_ts=timeseries_load,\n", + " dispatchable_generators_ts=timeseries_generation_dispatchable,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "91", + "metadata": {}, + "source": [ + "## Task\n", + "Plot the time series for three solar generators and gas power plants in individual plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_solar_generators = edisgo.timeseries.generators_active_power.loc[\n", + " :, edisgo.topology.generators_df[\"type\"] == \"solar\"\n", + "]\n", + "timeseries_solar_generators.iloc[:, :5].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_gas_generators = edisgo.timeseries.generators_active_power.loc[\n", + " :, edisgo.topology.generators_df[\"type\"] == \"gas\"\n", + "]\n", + "timeseries_gas_generators.iloc[:, :5].plot()" + ] + }, + { + "cell_type": "markdown", + "id": "94", + "metadata": {}, + "source": [ + "## Task\n", + "Plot the time series of three conventional loads and of three heat pumps in individual plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_heat_pumps = edisgo.timeseries.loads_active_power.loc[\n", + " :, edisgo.topology.loads_df[\"type\"] == \"conventional_load\"\n", + "]\n", + "timeseries_heat_pumps.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "96", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_heat_pumps = edisgo.timeseries.loads_active_power.loc[\n", + " :, edisgo.topology.loads_df[\"type\"] == \"heat_pump\"\n", + "]\n", + "timeseries_heat_pumps.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97", + "metadata": {}, + "outputs": [], + "source": [ + "# set heat pump time series\n", + "# set_time_series_active_power_predefined does not consider heat demand\n", + "edisgo.apply_heat_pump_operating_strategy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_heat_pumps = edisgo.timeseries.loads_active_power.loc[\n", + " :, edisgo.topology.loads_df[\"type\"] == \"heat_pump\"\n", + "]\n", + "timeseries_heat_pumps.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.loads_active_power" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "100", + "metadata": {}, + "outputs": [], + "source": [ + "# set battery storage time series (not inluded in set_time_series_active_power_predefined())\n", + "apply_reference_operation(edisgo)\n", + "# returns soe" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "101", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.storage_units_active_power.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "102", + "metadata": {}, + "outputs": [], + "source": [ + "# set reactive power time series\n", + "edisgo.set_time_series_reactive_power_control()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "103", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.generators_reactive_power" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/edisgo_simple_example.ipynb b/examples/edisgo_simple_example.ipynb index c7ee79ce1..31b493ead 100644 --- a/examples/edisgo_simple_example.ipynb +++ b/examples/edisgo_simple_example.ipynb @@ -892,7 +892,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.5" + "version": "3.11.0" }, "toc": { "base_numbering": 1, diff --git a/examples/electromobility_example.ipynb b/examples/electromobility_example.ipynb index 9ab632b71..259437467 100644 --- a/examples/electromobility_example.ipynb +++ b/examples/electromobility_example.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "e9100083", + "id": "0", "metadata": {}, "source": [ "# Electromobility example\n", @@ -17,7 +17,7 @@ }, { "cell_type": "markdown", - "id": "c74c4450", + "id": "1", "metadata": {}, "source": [ "## Installation and setup\n", @@ -27,7 +27,7 @@ }, { "cell_type": "markdown", - "id": "ecefffc4", + "id": "2", "metadata": {}, "source": [ "### Import packages" @@ -36,7 +36,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6898e8bd", + "id": "3", "metadata": { "tags": [] }, @@ -65,7 +65,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6b5c46ca", + "id": "4", "metadata": { "tags": [] }, @@ -77,7 +77,7 @@ }, { "cell_type": "markdown", - "id": "488bfb8c", + "id": "5", "metadata": {}, "source": [ "### Set up logger" @@ -86,7 +86,7 @@ { "cell_type": "code", "execution_count": null, - "id": "e3b60c43", + "id": "6", "metadata": { "tags": [] }, @@ -104,7 +104,7 @@ }, { "cell_type": "markdown", - "id": "fd735589", + "id": "7", "metadata": {}, "source": [ "### Download example grid" @@ -113,7 +113,7 @@ { "cell_type": "code", "execution_count": null, - "id": "afe44b3f", + "id": "8", "metadata": { "tags": [] }, @@ -155,7 +155,7 @@ }, { "cell_type": "markdown", - "id": "abddc320", + "id": "9", "metadata": {}, "source": [ "### Set up edisgo object" @@ -164,7 +164,7 @@ { "cell_type": "code", "execution_count": null, - "id": "b8a406ae", + "id": "10", "metadata": { "tags": [] }, @@ -191,7 +191,7 @@ { "cell_type": "code", "execution_count": null, - "id": "716fa083-0409-46a4-a55c-07cac583e387", + "id": "11", "metadata": { "tags": [] }, @@ -213,7 +213,7 @@ }, { "cell_type": "markdown", - "id": "4269ad12", + "id": "12", "metadata": {}, "source": [ "## Prerequisite data\n", @@ -225,7 +225,7 @@ }, { "cell_type": "markdown", - "id": "0ba78c69", + "id": "13", "metadata": {}, "source": [ "### Download 'Verwaltungsgebiete' data\n", @@ -235,7 +235,7 @@ }, { "cell_type": "markdown", - "id": "ccb74f72", + "id": "14", "metadata": {}, "source": [ "```python\n", @@ -265,7 +265,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3fdf5534", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -304,7 +304,7 @@ }, { "cell_type": "markdown", - "id": "b2e81602", + "id": "16", "metadata": {}, "source": [ "### Check which 'Verwaltungsgebiete' intersect MV grid" @@ -313,7 +313,7 @@ { "cell_type": "code", "execution_count": null, - "id": "d6bdc1f4", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -330,7 +330,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38e067dd", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -345,7 +345,7 @@ }, { "cell_type": "markdown", - "id": "e2082ea8-3be5-4e69-8b3b-26023bedc71b", + "id": "19", "metadata": {}, "source": [ "As most municipalities only intersect the grid district at its border, only the electromobility data for one municipality needs to be generated." @@ -354,7 +354,7 @@ { "cell_type": "code", "execution_count": null, - "id": "0d4e721d-6be2-4e41-b6d0-349f9bbc2f5b", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -369,7 +369,7 @@ }, { "cell_type": "markdown", - "id": "bfc8a701", + "id": "21", "metadata": {}, "source": [ "## Add electromobility to EDisGo object\n", @@ -410,7 +410,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c8f2e17e", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -453,7 +453,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8421b212", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -490,7 +490,7 @@ { "cell_type": "code", "execution_count": null, - "id": "1d65e6d6", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -503,7 +503,7 @@ }, { "cell_type": "markdown", - "id": "ae9955f1", + "id": "25", "metadata": {}, "source": [ "### eDisGo electromobility data structure \n", @@ -526,7 +526,7 @@ { "cell_type": "code", "execution_count": null, - "id": "0e859c1e-6aba-4457-92f5-59b1a4b4ddae", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -537,7 +537,7 @@ { "cell_type": "code", "execution_count": null, - "id": "964916d6-82fc-47fb-8ff4-d28173113128", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -548,7 +548,7 @@ { "cell_type": "code", "execution_count": null, - "id": "db648528-06dd-40cf-9fc0-4137280f21cb", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -559,7 +559,7 @@ { "cell_type": "code", "execution_count": null, - "id": "f6663f9f-2481-403d-b1d8-c0cf364d3eba", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -570,7 +570,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c71977c0-e4e0-443e-afa1-ed632c30c54b", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -580,7 +580,7 @@ { "cell_type": "code", "execution_count": null, - "id": "1b156984-4431-4312-a617-a23441e0d153", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -625,7 +625,7 @@ }, { "cell_type": "markdown", - "id": "b82b9f8f", + "id": "32", "metadata": {}, "source": [ "## Applying different charging strategies\n", @@ -635,7 +635,7 @@ }, { "cell_type": "markdown", - "id": "0cc6707b", + "id": "33", "metadata": {}, "source": [ "The eDisGo tool currently offers three different charging strategies: `dumb`, `reduced` and `residual`.\n", @@ -656,7 +656,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18455dcc-0db7-4ade-9003-6c183552a12b", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -668,7 +668,7 @@ { "cell_type": "code", "execution_count": null, - "id": "685108f9-f15b-459e-8f22-2d99c678fb1c", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -679,7 +679,7 @@ { "cell_type": "code", "execution_count": null, - "id": "b56ebbd4", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -695,7 +695,7 @@ }, { "cell_type": "markdown", - "id": "b9cd3434", + "id": "37", "metadata": {}, "source": [ "To change the charging strategy from the default `dumb` to one of the other strategies, the `strategy` parameter has to be set accordingly:" @@ -704,7 +704,7 @@ { "cell_type": "code", "execution_count": null, - "id": "a15eece2-951e-4749-9ab4-eaf3c22b0077", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -715,7 +715,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2b61d2e2", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -725,7 +725,7 @@ }, { "cell_type": "markdown", - "id": "3bd366aa-ea6e-4d1f-a66b-fee6bcaf3f4f", + "id": "40", "metadata": {}, "source": [ "**Plot charging time series for different charging strategies**" @@ -734,7 +734,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20d98ca8", + "id": "41", "metadata": { "tags": [] }, @@ -774,7 +774,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.18" + "version": "3.10.16" }, "toc": { "base_numbering": 1, diff --git a/examples/plot_example.ipynb b/examples/plot_example.ipynb index 4c71ecc1d..696c2a5a0 100644 --- a/examples/plot_example.ipynb +++ b/examples/plot_example.ipynb @@ -411,7 +411,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.16" + "version": "3.10.16" }, "toc": { "base_numbering": 1, diff --git a/setup.py b/setup.py index 715d0869a..618527ebd 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,8 @@ def read(fname): "shapely >= 1.7.0, < 2.1.0", "sqlalchemy < 1.4.0", "sshtunnel < 0.5.0", + # sshtunnel 0.4.0 incompatible with paramiko >= 3.5.0 (DSSKey removed) + "paramiko < 3.5.0", "urllib3 < 2.6.0", "workalendar < 17.1.0", "astroid == 3.3.11", diff --git a/tests/test_edisgo.py b/tests/test_edisgo.py index bbdbddfb5..96d4f4f1b 100755 --- a/tests/test_edisgo.py +++ b/tests/test_edisgo.py @@ -207,7 +207,7 @@ def test_set_time_series_active_power_predefined(self, caplog): # check warning self.edisgo.set_time_series_active_power_predefined() assert ( - "When setting time series using predefined profiles it is better" + "The EDisGo.TimeSeries.timeindex is empty. By default, this function" in caplog.text ) @@ -935,9 +935,9 @@ def test_aggregate_components(self): # ##### test without any aggregation - self.edisgo.topology._loads_df.at[ - "Load_residential_LVGrid_1_4", "bus" - ] = "Bus_BranchTee_LVGrid_1_10" + self.edisgo.topology._loads_df.at["Load_residential_LVGrid_1_4", "bus"] = ( + "Bus_BranchTee_LVGrid_1_10" + ) # save original values number_gens_before = len(self.edisgo.topology.generators_df) @@ -1055,9 +1055,9 @@ def test_aggregate_components(self): ) # manipulate grid so that more than one load of the same sector is # connected at the same bus - self.edisgo.topology._loads_df.at[ - "Load_residential_LVGrid_1_4", "bus" - ] = "Bus_BranchTee_LVGrid_1_10" + self.edisgo.topology._loads_df.at["Load_residential_LVGrid_1_4", "bus"] = ( + "Bus_BranchTee_LVGrid_1_10" + ) # save original values (only loads, as generators did not change) loads_p_set_before = self.edisgo.topology.loads_df.p_set.sum() @@ -1137,9 +1137,9 @@ def test_aggregate_components(self): # manipulate grid so that two generators of different types are # connected at the same bus - self.edisgo.topology._generators_df.at[ - "GeneratorFluctuating_13", "type" - ] = "misc" + self.edisgo.topology._generators_df.at["GeneratorFluctuating_13", "type"] = ( + "misc" + ) # save original values (values of loads were changed in previous aggregation) loads_p_set_before = self.edisgo.topology.loads_df.p_set.sum()