Note

You can download this example as a Jupyter notebook or start it in interactive mode.

Network Clustering#

In this example, we show how pypsa can deal with spatial clustering of networks.

[1]:
import pypsa
import re
import numpy as np
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import pandas as pd
[2]:
n = pypsa.examples.scigrid_de()
n.calculate_dependent_values()
n.lines["type"] = np.nan  # delete the 'type' specifications to make this example easier
WARNING:pypsa.io:Importing network from PyPSA version v0.17.1 while current version is v0.25.0. Read the release notes at https://pypsa.readthedocs.io/en/latest/release_notes.html to prepare your network for import.
INFO:pypsa.io:Imported network scigrid-de.nc has buses, generators, lines, loads, storage_units, transformers

The important information that pypsa needs for spatial clustering is in the busmap. It contains the mapping of which buses should be grouped together, similar to the groupby groups as we know it from pandas.

You can either calculate a busmap from the provided clustering algorithms or you can create/use your own busmap.

Cluster by custom busmap#

Let’s start with creating our own. In the following, we group all buses together which belong to the same operator. Buses which do not have a specific operator just stay on its own.

[3]:
groups = n.buses.operator.apply(lambda x: re.split(" |,|;", x)[0])
busmap = groups.where(groups != "", n.buses.index)

Now we cluster the network based on the busmap.

[4]:
C = n.cluster.get_clustering_from_busmap(busmap)
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[4], line 1
----> 1 C = n.cluster.get_clustering_from_busmap(busmap)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/__init__.py:61, in ClusteringAccessor.get_clustering_from_busmap(self, *args, **kwargs)
     59 @wraps(spatial.get_clustering_from_busmap)
     60 def get_clustering_from_busmap(self, *args, **kwargs):
---> 61     return spatial.get_clustering_from_busmap(self._parent, *args, **kwargs)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/spatial.py:514, in get_clustering_from_busmap(n, busmap, with_time, line_length_factor, aggregate_generators_weighted, aggregate_one_ports, aggregate_generators_carriers, scale_link_capital_costs, bus_strategies, one_port_strategies, generator_strategies, aggregate_generators_buses)
    511     aggregate_one_ports = {}
    512 from pypsa.components import Network
--> 514 buses, lines, lines_t, linemap = get_buses_linemap_and_lines(
    515     n, busmap, line_length_factor, bus_strategies, with_time
    516 )
    518 clustered = Network()
    520 io.import_components_from_dataframe(clustered, buses, "Bus")

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/spatial.py:478, in get_buses_linemap_and_lines(n, busmap, line_length_factor, bus_strategies, with_time)
    476 if bus_strategies is None:
    477     bus_strategies = {}
--> 478 buses = aggregatebuses(n, busmap, custom_strategies=bus_strategies)
    479 lines, lines_t, linemap = aggregatelines(
    480     n,
    481     busmap,
   (...)
    484     bus_strategies=bus_strategies,
    485 )
    486 return buses, lines, lines_t, linemap

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/spatial.py:333, in aggregatebuses(n, busmap, custom_strategies)
    330 strategies = {**DEFAULT_BUS_STRATEGIES, **custom_strategies}
    331 strategies = align_strategies(strategies, columns, c)
--> 333 aggregated = n.buses.groupby(busmap).agg(strategies)
    334 aggregated.index = flatten_multiindex(aggregated.index).rename(c)
    336 return aggregated

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/generic.py:1269, in DataFrameGroupBy.aggregate(self, func, engine, engine_kwargs, *args, **kwargs)
   1266 func = maybe_mangle_lambdas(func)
   1268 op = GroupByApply(self, func, args, kwargs)
-> 1269 result = op.agg()
   1270 if not is_dict_like(func) and result is not None:
   1271     return result

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/apply.py:163, in Apply.agg(self)
    160     return self.apply_str()
    162 if is_dict_like(arg):
--> 163     return self.agg_dict_like()
    164 elif is_list_like(arg):
    165     # we require a list, but not a 'str'
    166     return self.agg_list_like()

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/apply.py:420, in Apply.agg_dict_like(self)
    417         results = {key: colg.agg(how) for key, how in arg.items()}
    418     else:
    419         # key used for column selection and output
--> 420         results = {
    421             key: obj._gotitem(key, ndim=1).agg(how) for key, how in arg.items()
    422         }
    424 # set the final keys
    425 keys = list(arg.keys())

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/apply.py:421, in <dictcomp>(.0)
    417         results = {key: colg.agg(how) for key, how in arg.items()}
    418     else:
    419         # key used for column selection and output
    420         results = {
--> 421             key: obj._gotitem(key, ndim=1).agg(how) for key, how in arg.items()
    422         }
    424 # set the final keys
    425 keys = list(arg.keys())

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/generic.py:269, in SeriesGroupBy.aggregate(self, func, engine, engine_kwargs, *args, **kwargs)
    266     return self._python_agg_general(func, *args, **kwargs)
    268 try:
--> 269     return self._python_agg_general(func, *args, **kwargs)
    270 except KeyError:
    271     # KeyError raised in test_groupby.test_basic is bc the func does
    272     #  a dictionary lookup on group.name, but group name is not
    273     #  pinned in _python_agg_general, only in _aggregate_named
    274     result = self._aggregate_named(func, *args, **kwargs)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/generic.py:288, in SeriesGroupBy._python_agg_general(self, func, *args, **kwargs)
    285 f = lambda x: func(x, *args, **kwargs)
    287 obj = self._obj_with_exclusions
--> 288 result = self.grouper.agg_series(obj, f)
    289 res = obj._constructor(result, name=obj.name)
    290 return self._wrap_aggregated_output(res)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/ops.py:994, in BaseGrouper.agg_series(self, obj, func, preserve_dtype)
    987 if len(obj) > 0 and not isinstance(obj._values, np.ndarray):
    988     # we can preserve a little bit more aggressively with EA dtype
    989     #  because maybe_cast_pointwise_result will do a try/except
    990     #  with _from_sequence.  NB we are assuming here that _from_sequence
    991     #  is sufficiently strict that it casts appropriately.
    992     preserve_dtype = True
--> 994 result = self._aggregate_series_pure_python(obj, func)
    996 npvalues = lib.maybe_convert_objects(result, try_float=False)
    997 if preserve_dtype:

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/ops.py:1015, in BaseGrouper._aggregate_series_pure_python(self, obj, func)
   1012 splitter = self._get_splitter(obj, axis=0)
   1014 for i, group in enumerate(splitter):
-> 1015     res = func(group)
   1016     res = libreduction.extract_result(res)
   1018     if not initialized:
   1019         # We only do this validation on the first iteration

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/generic.py:285, in SeriesGroupBy._python_agg_general.<locals>.<lambda>(x)
    283 def _python_agg_general(self, func, *args, **kwargs):
    284     func = com.is_builtin_func(func)
--> 285     f = lambda x: func(x, *args, **kwargs)
    287     obj = self._obj_with_exclusions
    288     result = self.grouper.agg_series(obj, f)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/spatial.py:136, in make_consense.<locals>.consense(x)
    134 def consense(x: Series) -> object:
    135     v = x.iat[0]
--> 136     assert (x == v).all() or x.isnull().all(), (
    137         f"In {component} cluster {x.name}, the values of attribute "
    138         f"{attr} do not agree:\n{x}"
    139     )
    140     return v

AssertionError: In Bus cluster frequency, the values of attribute frequency do not agree:
Bus
8
10
11           50
12           50
17           50
             ..
78_220kV     50
279_220kV    50
281_220kV
282_220kV    50
292_220kV    50
Name: frequency, Length: 73, dtype: object

C is a Clustering object which contains all important information. Among others, the new network is now stored in that Clustering object.

[5]:
nc = C.network
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 nc = C.network

NameError: name 'C' is not defined

We have a look at the original and the clustered topology

[6]:
fig, (ax, ax1) = plt.subplots(
    1, 2, subplot_kw={"projection": ccrs.EqualEarth()}, figsize=(12, 12)
)
plot_kwrgs = dict(bus_sizes=1e-3, line_widths=0.5)
n.plot(ax=ax, title="original", **plot_kwrgs)
nc.plot(ax=ax1, title="clustered by operator", **plot_kwrgs)
fig.tight_layout()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[6], line 6
      4 plot_kwrgs = dict(bus_sizes=1e-3, line_widths=0.5)
      5 n.plot(ax=ax, title="original", **plot_kwrgs)
----> 6 nc.plot(ax=ax1, title="clustered by operator", **plot_kwrgs)
      7 fig.tight_layout()

NameError: name 'nc' is not defined
/home/docs/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/cartopy/mpl/style.py:76: UserWarning: facecolor will have no effect as it has been defined as "never".
  warnings.warn('facecolor will have no effect as it has been '
../_images/examples_spatial-clustering_13_2.png

Looks a bit messy as over 120 buses do not have assigned operators.

Clustering by busmap created from K-means#

Let’s now make a clustering based on the kmeans algorithm. Therefore we calculate the busmap from a non-weighted kmeans clustering.

[7]:
weighting = pd.Series(1, n.buses.index)
busmap2 = n.cluster.busmap_by_kmeans(bus_weightings=weighting, n_clusters=50)

We use this new kmeans-based busmap to create a new clustered method.

[8]:
nc2 = n.cluster.cluster_by_busmap(busmap2)
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[8], line 1
----> 1 nc2 = n.cluster.cluster_by_busmap(busmap2)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/__init__.py:57, in ClusteringAccessor.cluster_by_busmap(self, *args, **kwargs)
     46 def cluster_by_busmap(self, *args, **kwargs):
     47     """
     48     Cluster the network spatially by busmap.
     49
   (...)
     55     network : pypsa.Network
     56     """
---> 57     return spatial.get_clustering_from_busmap(self._parent, *args, **kwargs).network

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/spatial.py:514, in get_clustering_from_busmap(n, busmap, with_time, line_length_factor, aggregate_generators_weighted, aggregate_one_ports, aggregate_generators_carriers, scale_link_capital_costs, bus_strategies, one_port_strategies, generator_strategies, aggregate_generators_buses)
    511     aggregate_one_ports = {}
    512 from pypsa.components import Network
--> 514 buses, lines, lines_t, linemap = get_buses_linemap_and_lines(
    515     n, busmap, line_length_factor, bus_strategies, with_time
    516 )
    518 clustered = Network()
    520 io.import_components_from_dataframe(clustered, buses, "Bus")

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/spatial.py:478, in get_buses_linemap_and_lines(n, busmap, line_length_factor, bus_strategies, with_time)
    476 if bus_strategies is None:
    477     bus_strategies = {}
--> 478 buses = aggregatebuses(n, busmap, custom_strategies=bus_strategies)
    479 lines, lines_t, linemap = aggregatelines(
    480     n,
    481     busmap,
   (...)
    484     bus_strategies=bus_strategies,
    485 )
    486 return buses, lines, lines_t, linemap

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/spatial.py:333, in aggregatebuses(n, busmap, custom_strategies)
    330 strategies = {**DEFAULT_BUS_STRATEGIES, **custom_strategies}
    331 strategies = align_strategies(strategies, columns, c)
--> 333 aggregated = n.buses.groupby(busmap).agg(strategies)
    334 aggregated.index = flatten_multiindex(aggregated.index).rename(c)
    336 return aggregated

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/generic.py:1269, in DataFrameGroupBy.aggregate(self, func, engine, engine_kwargs, *args, **kwargs)
   1266 func = maybe_mangle_lambdas(func)
   1268 op = GroupByApply(self, func, args, kwargs)
-> 1269 result = op.agg()
   1270 if not is_dict_like(func) and result is not None:
   1271     return result

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/apply.py:163, in Apply.agg(self)
    160     return self.apply_str()
    162 if is_dict_like(arg):
--> 163     return self.agg_dict_like()
    164 elif is_list_like(arg):
    165     # we require a list, but not a 'str'
    166     return self.agg_list_like()

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/apply.py:420, in Apply.agg_dict_like(self)
    417         results = {key: colg.agg(how) for key, how in arg.items()}
    418     else:
    419         # key used for column selection and output
--> 420         results = {
    421             key: obj._gotitem(key, ndim=1).agg(how) for key, how in arg.items()
    422         }
    424 # set the final keys
    425 keys = list(arg.keys())

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/apply.py:421, in <dictcomp>(.0)
    417         results = {key: colg.agg(how) for key, how in arg.items()}
    418     else:
    419         # key used for column selection and output
    420         results = {
--> 421             key: obj._gotitem(key, ndim=1).agg(how) for key, how in arg.items()
    422         }
    424 # set the final keys
    425 keys = list(arg.keys())

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/generic.py:269, in SeriesGroupBy.aggregate(self, func, engine, engine_kwargs, *args, **kwargs)
    266     return self._python_agg_general(func, *args, **kwargs)
    268 try:
--> 269     return self._python_agg_general(func, *args, **kwargs)
    270 except KeyError:
    271     # KeyError raised in test_groupby.test_basic is bc the func does
    272     #  a dictionary lookup on group.name, but group name is not
    273     #  pinned in _python_agg_general, only in _aggregate_named
    274     result = self._aggregate_named(func, *args, **kwargs)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/generic.py:288, in SeriesGroupBy._python_agg_general(self, func, *args, **kwargs)
    285 f = lambda x: func(x, *args, **kwargs)
    287 obj = self._obj_with_exclusions
--> 288 result = self.grouper.agg_series(obj, f)
    289 res = obj._constructor(result, name=obj.name)
    290 return self._wrap_aggregated_output(res)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/ops.py:994, in BaseGrouper.agg_series(self, obj, func, preserve_dtype)
    987 if len(obj) > 0 and not isinstance(obj._values, np.ndarray):
    988     # we can preserve a little bit more aggressively with EA dtype
    989     #  because maybe_cast_pointwise_result will do a try/except
    990     #  with _from_sequence.  NB we are assuming here that _from_sequence
    991     #  is sufficiently strict that it casts appropriately.
    992     preserve_dtype = True
--> 994 result = self._aggregate_series_pure_python(obj, func)
    996 npvalues = lib.maybe_convert_objects(result, try_float=False)
    997 if preserve_dtype:

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/ops.py:1015, in BaseGrouper._aggregate_series_pure_python(self, obj, func)
   1012 splitter = self._get_splitter(obj, axis=0)
   1014 for i, group in enumerate(splitter):
-> 1015     res = func(group)
   1016     res = libreduction.extract_result(res)
   1018     if not initialized:
   1019         # We only do this validation on the first iteration

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pandas/core/groupby/generic.py:285, in SeriesGroupBy._python_agg_general.<locals>.<lambda>(x)
    283 def _python_agg_general(self, func, *args, **kwargs):
    284     func = com.is_builtin_func(func)
--> 285     f = lambda x: func(x, *args, **kwargs)
    287     obj = self._obj_with_exclusions
    288     result = self.grouper.agg_series(obj, f)

File ~/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/pypsa/clustering/spatial.py:136, in make_consense.<locals>.consense(x)
    134 def consense(x: Series) -> object:
    135     v = x.iat[0]
--> 136     assert (x == v).all() or x.isnull().all(), (
    137         f"In {component} cluster {x.name}, the values of attribute "
    138         f"{attr} do not agree:\n{x}"
    139     )
    140     return v

AssertionError: In Bus cluster frequency, the values of attribute frequency do not agree:
Bus
88
89           50
105
129          50
137
153          50
170          50
171
172          50
173          50
174          50
175
176          50
184          50
185          50
186          50
187
188
191          50
193          50
194
195
196
197          50
200          50
455          50
456          50
463          50
468          50
469          50
105_220kV
129_220kV    50
184_220kV    50
191_220kV    50
195_220kV
Name: frequency, dtype: object

Again, let’s plot the networks to compare:

[9]:
fig, (ax, ax1) = plt.subplots(
    1, 2, subplot_kw={"projection": ccrs.EqualEarth()}, figsize=(12, 12)
)
plot_kwrgs = dict(bus_sizes=1e-3, line_widths=0.5)
n.plot(ax=ax, title="original", **plot_kwrgs)
nc2.plot(ax=ax1, title="clustered by kmeans", **plot_kwrgs)
fig.tight_layout()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[9], line 6
      4 plot_kwrgs = dict(bus_sizes=1e-3, line_widths=0.5)
      5 n.plot(ax=ax, title="original", **plot_kwrgs)
----> 6 nc2.plot(ax=ax1, title="clustered by kmeans", **plot_kwrgs)
      7 fig.tight_layout()

NameError: name 'nc2' is not defined
/home/docs/checkouts/readthedocs.org/user_builds/pypsa/conda/v0.25.0/lib/python3.11/site-packages/cartopy/mpl/style.py:76: UserWarning: facecolor will have no effect as it has been defined as "never".
  warnings.warn('facecolor will have no effect as it has been '
../_images/examples_spatial-clustering_21_2.png

There are other clustering algorithms in the pipeline of pypsa as the hierarchical clustering which performs better than the kmeans. Also the get_clustering_from_busmap function supports various arguments on how components in the network should be aggregated.