Repository: PyPSA/PyPSA

Issue:
By default, have multilinks in pypsa networks
options:

- create columns and semantics on the fly
- n = pypsa.Network(multilinks=3)

Discussion:

Patch:
diff --git a/doc/components.rst b/doc/components.rst
index 9fbbe20e..7ba4abeb 100644
--- a/doc/components.rst
+++ b/doc/components.rst
@@ -438,18 +438,10 @@ these are used in the LOPF and the `example of a CHP with a fixed
 power-heat ratio
 <https://pypsa.readthedocs.io/en/latest/examples/chp-fixed-heat-power-ratio.html>`_.
 
-To define the new columns ``bus2``, ``efficiency2``, ``bus3``,
-``efficiency3``, etc. in ``network.links`` you need to override the
-standard component attributes by passing ``pypsa.Network()`` an
-``override_component_attrs`` argument. See the section
-:ref:`custom_components` and the `example of a CHP with a fixed
-power-heat ratio
-<https://pypsa.readthedocs.io/en/latest/examples/chp-fixed-heat-power-ratio.html>`_.
-
-
-If the column ``bus2`` exists, values in the column are not compulsory
-for all links; if the link has no 2nd output, simply leave it empty
-``network.links.at["my_link","bus2"] = ""``.
+The columns ``bus2``, ``efficiency2``, ``bus3``, ``efficiency3``, etc. in
+``network.links`` are automatically added to the component attributes. The
+values in these columns are not compulsory; if the link has no 2nd output,
+simply leave it empty ``network.links.at["my_link","bus2"] = ""`` or as NaN.
 
 For links with multiple inputs in fixed ratio to one of the inputs,
 you can define the other inputs as outputs with a negative efficiency
diff --git a/doc/release_notes.rst b/doc/release_notes.rst
index f3cf0171..ed271597 100644
--- a/doc/release_notes.rst
+++ b/doc/release_notes.rst
@@ -11,9 +11,18 @@ Upcoming Release
   now allows the passing of a list of buses for which aggregation of __all__ carriers is desired. Generation from
   a carrier at a bus is aggregated now if: It is __either__ in the passed list of aggregated carriers, __or__ in the
   list of aggregated buses.
+* PyPSA now supports stand-by cost terms. A new column
+  `stand_by_cost` was added to generators and links. The stand-by
+  cost is added to the objective function when calling
+  ``n.optimize()``.
+* Links with multiple inputs/outputs are now supported by default. The Link
+  component attributes are automatically extended if a link with ``bus2``,
+  ``bus3``, etc. are added to the network. Overriding component attributes
+  at network initialisation is no longer required.
 * The ``n.optimize`` accessor now provides functionality for rolling horizon optimisation using ``n.optimize.optimize_with_rolling_horizon()`` which splits whole optimization of the whole time span into multiple subproblems which are solved consecutively. This is useful for operational optimizations with a high spatial resolution.
 * PyPSA now supports stand-by cost terms. A new column`stand_by_cost` was added to generators and links. The stand-by cost is added to the objective function when calling ``n.optimize()``.
 
+
 PyPSA 0.24.0 (27th June 2023)
 =================================
 
diff --git a/examples/notebooks/biomass-synthetic-fuels-carbon-management.ipynb b/examples/notebooks/biomass-synthetic-fuels-carbon-management.ipynb
index 8cc30753..2b81b3ea 100644
--- a/examples/notebooks/biomass-synthetic-fuels-carbon-management.ipynb
+++ b/examples/notebooks/biomass-synthetic-fuels-carbon-management.ipynb
@@ -1,6 +1,7 @@
 {
  "cells": [
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -26,79 +27,18 @@
     "import matplotlib.pyplot as plt"
    ]
   },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "First tell PyPSA that links can have multiple outputs by overriding the component_attrs. This can be done for as many buses as you need with format busi for i = 2,3,4,5,.... \n",
-    "\n",
-    "See https://pypsa.org/doc/components.html#link-with-multiple-outputs-or-inputs"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "override_component_attrs = pypsa.descriptors.Dict(\n",
-    "    {k: v.copy() for k, v in pypsa.components.component_attrs.items()}\n",
-    ")\n",
-    "override_component_attrs[\"Link\"].loc[\"bus2\"] = [\n",
-    "    \"string\",\n",
-    "    np.nan,\n",
-    "    np.nan,\n",
-    "    \"2nd bus\",\n",
-    "    \"Input (optional)\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"bus3\"] = [\n",
-    "    \"string\",\n",
-    "    np.nan,\n",
-    "    np.nan,\n",
-    "    \"3rd bus\",\n",
-    "    \"Input (optional)\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"efficiency2\"] = [\n",
-    "    \"static or series\",\n",
-    "    \"per unit\",\n",
-    "    1.0,\n",
-    "    \"2nd bus efficiency\",\n",
-    "    \"Input (optional)\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"efficiency3\"] = [\n",
-    "    \"static or series\",\n",
-    "    \"per unit\",\n",
-    "    1.0,\n",
-    "    \"3rd bus efficiency\",\n",
-    "    \"Input (optional)\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"p2\"] = [\n",
-    "    \"series\",\n",
-    "    \"MW\",\n",
-    "    0.0,\n",
-    "    \"2nd bus output\",\n",
-    "    \"Output\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"p3\"] = [\n",
-    "    \"series\",\n",
-    "    \"MW\",\n",
-    "    0.0,\n",
-    "    \"3rd bus output\",\n",
-    "    \"Output\",\n",
-    "]"
-   ]
-  },
   {
    "cell_type": "code",
    "execution_count": null,
    "metadata": {},
    "outputs": [],
    "source": [
-    "n = pypsa.Network(override_component_attrs=override_component_attrs)\n",
+    "n = pypsa.Network()\n",
     "n.set_snapshots(range(10))"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -116,6 +56,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -139,6 +80,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -163,6 +105,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -204,6 +147,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -229,6 +173,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -283,6 +228,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -340,6 +286,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -371,6 +318,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -388,6 +336,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -407,6 +356,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
diff --git a/examples/notebooks/chained-hydro-reservoirs.ipynb b/examples/notebooks/chained-hydro-reservoirs.ipynb
index 91279045..ceacd44d 100644
--- a/examples/notebooks/chained-hydro-reservoirs.ipynb
+++ b/examples/notebooks/chained-hydro-reservoirs.ipynb
@@ -1,6 +1,7 @@
 {
  "cells": [
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -23,56 +24,18 @@
     "import matplotlib.pyplot as plt"
    ]
   },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "First tell PyPSA that links will have a 2nd bus by overriding the component_attrs. This is needed so that water can both go through a turbine AND feed the next reservoir"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "override_component_attrs = pypsa.descriptors.Dict(\n",
-    "    {k: v.copy() for k, v in pypsa.components.component_attrs.items()}\n",
-    ")\n",
-    "override_component_attrs[\"Link\"].loc[\"bus2\"] = [\n",
-    "    \"string\",\n",
-    "    np.nan,\n",
-    "    np.nan,\n",
-    "    \"2nd bus\",\n",
-    "    \"Input (optional)\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"efficiency2\"] = [\n",
-    "    \"static or series\",\n",
-    "    \"per unit\",\n",
-    "    1.0,\n",
-    "    \"2nd bus efficiency\",\n",
-    "    \"Input (optional)\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"p2\"] = [\n",
-    "    \"series\",\n",
-    "    \"MW\",\n",
-    "    0.0,\n",
-    "    \"2nd bus output\",\n",
-    "    \"Output\",\n",
-    "]"
-   ]
-  },
   {
    "cell_type": "code",
    "execution_count": null,
    "metadata": {},
    "outputs": [],
    "source": [
-    "network = pypsa.Network(override_component_attrs=override_component_attrs)\n",
+    "network = pypsa.Network()\n",
     "network.set_snapshots(pd.date_range(\"2016-01-01 00:00\", \"2016-01-01 03:00\", freq=\"H\"))"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -111,6 +74,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -187,6 +151,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -226,6 +191,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
diff --git a/examples/notebooks/chp-fixed-heat-power-ratio.ipynb b/examples/notebooks/chp-fixed-heat-power-ratio.ipynb
index 45d24392..334b495a 100644
--- a/examples/notebooks/chp-fixed-heat-power-ratio.ipynb
+++ b/examples/notebooks/chp-fixed-heat-power-ratio.ipynb
@@ -1,6 +1,7 @@
 {
  "cells": [
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -24,52 +25,13 @@
     "import pypsa, numpy as np"
    ]
   },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "First tell PyPSA that links will have a 2nd bus by overriding the component_attrs. This can be done for as many buses as you need with format busi for i = 2,3,4,5,...."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "override_component_attrs = pypsa.descriptors.Dict(\n",
-    "    {k: v.copy() for k, v in pypsa.components.component_attrs.items()}\n",
-    ")\n",
-    "override_component_attrs[\"Link\"].loc[\"bus2\"] = [\n",
-    "    \"string\",\n",
-    "    np.nan,\n",
-    "    np.nan,\n",
-    "    \"2nd bus\",\n",
-    "    \"Input (optional)\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"efficiency2\"] = [\n",
-    "    \"static or series\",\n",
-    "    \"per unit\",\n",
-    "    1.0,\n",
-    "    \"2nd bus efficiency\",\n",
-    "    \"Input (optional)\",\n",
-    "]\n",
-    "override_component_attrs[\"Link\"].loc[\"p2\"] = [\n",
-    "    \"series\",\n",
-    "    \"MW\",\n",
-    "    0.0,\n",
-    "    \"2nd bus output\",\n",
-    "    \"Output\",\n",
-    "]"
-   ]
-  },
   {
    "cell_type": "code",
    "execution_count": null,
    "metadata": {},
    "outputs": [],
    "source": [
-    "network = pypsa.Network(override_component_attrs=override_component_attrs)\n",
+    "network = pypsa.Network()\n",
     "\n",
     "network.add(\"Bus\", \"Frankfurt\", carrier=\"AC\")\n",
     "network.add(\"Load\", \"Frankfurt\", bus=\"Frankfurt\", p_set=5)\n",
diff --git a/examples/sector-coupling/biomass-synthetic-fuels-carbon-management.py b/examples/sector-coupling/biomass-synthetic-fuels-carbon-management.py
index 99316356..99af900e 100644
--- a/examples/sector-coupling/biomass-synthetic-fuels-carbon-management.py
+++ b/examples/sector-coupling/biomass-synthetic-fuels-carbon-management.py
@@ -18,58 +18,7 @@ import numpy as np
 
 import pypsa
 
-# First tell PyPSA that links can have multiple outputs by
-# overriding the component_attrs. This can be done for
-# as many buses as you need with format busi for i = 2, 3, 4, 5, ....
-# See https://pypsa.readthedocs.io/en/latest/components.html#link-with-multiple-outputs-or-inputs
-
-override_component_attrs = pypsa.descriptors.Dict(
-    {k: v.copy() for k, v in pypsa.components.component_attrs.items()}
-)
-override_component_attrs["Link"].loc["bus2"] = [
-    "string",
-    np.nan,
-    np.nan,
-    "2nd bus",
-    "Input (optional)",
-]
-override_component_attrs["Link"].loc["bus3"] = [
-    "string",
-    np.nan,
-    np.nan,
-    "3rd bus",
-    "Input (optional)",
-]
-override_component_attrs["Link"].loc["efficiency2"] = [
-    "static or series",
-    "per unit",
-    1.0,
-    "2nd bus efficiency",
-    "Input (optional)",
-]
-override_component_attrs["Link"].loc["efficiency3"] = [
-    "static or series",
-    "per unit",
-    1.0,
-    "3rd bus efficiency",
-    "Input (optional)",
-]
-override_component_attrs["Link"].loc["p2"] = [
-    "series",
-    "MW",
-    0.0,
-    "2nd bus output",
-    "Output",
-]
-override_component_attrs["Link"].loc["p3"] = [
-    "series",
-    "MW",
-    0.0,
-    "3rd bus output",
-    "Output",
-]
-
-n = pypsa.Network(override_component_attrs=override_component_attrs)
+n = pypsa.Network()
 
 n.set_snapshots(range(10))
 
diff --git a/examples/sector-coupling/chained-hydro-reservoirs.py b/examples/sector-coupling/chained-hydro-reservoirs.py
index 01c4901c..82440b96 100644
--- a/examples/sector-coupling/chained-hydro-reservoirs.py
+++ b/examples/sector-coupling/chained-hydro-reservoirs.py
@@ -10,37 +10,7 @@ import pandas as pd
 
 import pypsa
 
-# First tell PyPSA that links will have a 2nd bus by
-# overriding the component_attrs. This is needed so that
-# water can both go through a turbine AND feed the next reservoir
-
-override_component_attrs = pypsa.descriptors.Dict(
-    {k: v.copy() for k, v in pypsa.components.component_attrs.items()}
-)
-override_component_attrs["Link"].loc["bus2"] = [
-    "string",
-    np.nan,
-    np.nan,
-    "2nd bus",
-    "Input (optional)",
-]
-override_component_attrs["Link"].loc["efficiency2"] = [
-    "static or series",
-    "per unit",
-    1.0,
-    "2nd bus efficiency",
-    "Input (optional)",
-]
-override_component_attrs["Link"].loc["p2"] = [
-    "series",
-    "MW",
-    0.0,
-    "2nd bus output",
-    "Output",
-]
-
-
-network = pypsa.Network(override_component_attrs=override_component_attrs)
+network = pypsa.Network()
 
 network.set_snapshots(pd.date_range("2016-01-01 00:00", "2016-01-01 03:00", freq="H"))
 
diff --git a/examples/sector-coupling/chp-fixed-heat-power-ratio.py b/examples/sector-coupling/chp-fixed-heat-power-ratio.py
index 7fad541d..13613254 100644
--- a/examples/sector-coupling/chp-fixed-heat-power-ratio.py
+++ b/examples/sector-coupling/chp-fixed-heat-power-ratio.py
@@ -11,37 +11,7 @@ import numpy as np
 
 import pypsa
 
-# First tell PyPSA that links will have a 2nd bus by
-# overriding the component_attrs. This can be done for
-# as many buses as you need with format busi for i = 2, 3, 4, 5, ....
-
-override_component_attrs = pypsa.descriptors.Dict(
-    {k: v.copy() for k, v in pypsa.components.component_attrs.items()}
-)
-override_component_attrs["Link"].loc["bus2"] = [
-    "string",
-    np.nan,
-    np.nan,
-    "2nd bus",
-    "Input (optional)",
-]
-override_component_attrs["Link"].loc["efficiency2"] = [
-    "static or series",
-    "per unit",
-    1.0,
-    "2nd bus efficiency",
-    "Input (optional)",
-]
-override_component_attrs["Link"].loc["p2"] = [
-    "series",
-    "MW",
-    0.0,
-    "2nd bus output",
-    "Output",
-]
-
-
-network = pypsa.Network(override_component_attrs=override_component_attrs)
+network = pypsa.Network()
 
 network.add("Bus", "Frankfurt", carrier="AC")
 
diff --git a/pypsa/component_attrs/links.csv b/pypsa/component_attrs/links.csv
index 3be337af..f924c46f 100644
--- a/pypsa/component_attrs/links.csv
+++ b/pypsa/component_attrs/links.csv
@@ -1,7 +1,7 @@
 attribute,type,unit,default,description,status
 name,string,n/a,n/a,Unique name,Input (required)
 bus0,string,n/a,n/a,Name of first bus to which link is attached.,Input (required)
-bus1,string,n/a,n/a,Name of second bus to which link is attached.,Input (required)
+bus1,string,n/a,n/a,Name of other bus to which link is attached.,Input (required)
 type,string,n/a,n/a,Placeholder for link type. Not yet implemented.,Input (optional)
 carrier,"string","n/a","","Energy carrier transported by the link: can be ""DC"" for electrical HVDC links, or ""heat"" or ""gas"" etc.","Input (optional)"
 efficiency,static or series,per unit,1,Efficiency of power transfer from bus0 to bus1. (Can be time-dependent to represent temperature-dependent Coefficient of Performance of a heat pump from an electric to a heat bus.),Input (optional)
diff --git a/pypsa/components.py b/pypsa/components.py
index c2ba35c7..472301f7 100644
--- a/pypsa/components.py
+++ b/pypsa/components.py
@@ -34,6 +34,7 @@ from pypsa.descriptors import (
     get_extendable_i,
     get_non_extendable_i,
     get_switchable_as_dense,
+    update_linkports_component_attrs,
 )
 from pypsa.graph import adjacency_matrix, graph, incidence_matrix
 from pypsa.io import (
@@ -851,6 +852,9 @@ class Network(Basic):
             f"an object with this name in {self.components[class_name]['list_name']}"
         )
 
+        if class_name == "Link":
+            update_linkports_component_attrs(self, kwargs.keys())
+
         attrs = self.components[class_name]["attrs"]
 
         static_attrs = attrs[attrs.static].drop("name")
diff --git a/pypsa/descriptors.py b/pypsa/descriptors.py
index 382104bc..494ed35a 100644
--- a/pypsa/descriptors.py
+++ b/pypsa/descriptors.py
@@ -14,7 +14,7 @@ __copyright__ = (
 import logging
 import re
 from collections import OrderedDict
-from itertools import repeat
+from itertools import product, repeat
 
 import networkx as nx
 import numpy as np
@@ -431,9 +431,33 @@ def get_bounds_pu(n, c, sns, index=None, attr=None):
         return min_pu.reindex(columns=index), max_pu.reindex(columns=index)
 
 
-def additional_linkports(n):
-    return [
-        i[3:]
-        for i in n.links.columns
-        if i.startswith("bus") and i not in ["bus0", "bus1"]
-    ]
+def update_linkports_component_attrs(n, where=None):
+    ports = additional_linkports(n, where)
+    c = "Link"
+
+    def doc_changes(s, j, i):
+        if not isinstance(s, str):
+            return s
+        return s.replace(j, str(i)).replace("required", "optional")
+
+    for i, attr in product(ports, ["bus", "efficiency", "p"]):
+        target = f"{attr}{i}"
+        j = "1" if attr != "efficiency" else ""
+        n.components[c]["attrs"].loc[target] = (
+            n.components[c]["attrs"].loc[attr + j].apply(doc_changes, args=(j, i))
+        )
+        n.component_attrs[c].loc[target] = (
+            n.component_attrs[c].loc[attr + j].apply(doc_changes, args=(j, i))
+        )
+        if attr in ["efficiency", "p"] and not target in n.pnl(c).keys():
+            df = pd.DataFrame(index=n.snapshots, columns=[], dtype=float)
+            df.index.name = "snapshot"
+            df.columns.name = c
+            n.pnl(c)[target] = df
+
+
+def additional_linkports(n, where=None):
+    if not where:
+        where = n.links.columns
+    ports = [i[3:] for i in where if i.startswith("bus") and i not in ["bus0", "bus1"]]
+    return ports
diff --git a/pypsa/opf.py b/pypsa/opf.py
index 79538ee5..042912e9 100644
--- a/pypsa/opf.py
+++ b/pypsa/opf.py
@@ -38,7 +38,7 @@ import logging
 logger = logging.getLogger(__name__)
 
 
-from pypsa.descriptors import allocate_series_dataframes
+from pypsa.descriptors import additional_linkports, allocate_series_dataframes
 from pypsa.descriptors import get_switchable_as_dense as get_as_dense
 from pypsa.descriptors import get_switchable_as_iter, zsum
 from pypsa.opt import (
@@ -1526,14 +1526,12 @@ def define_nodal_balances(network, snapshots):
             )
 
     # Add any other buses to which the links are attached
-    for i in [
-        int(col[3:])
-        for col in network.links.columns
-        if col[:3] == "bus" and col not in ["bus0", "bus1"]
-    ]:
-        efficiency = get_as_dense(network, "Link", "efficiency{}".format(i), snapshots)
-        for cb in network.links.index[network.links["bus{}".format(i)] != ""]:
-            bus = network.links.at[cb, "bus{}".format(i)]
+    for i in additional_linkports(network):
+        efficiency = get_as_dense(network, "Link", f"efficiency{i}", snapshots)
+        for cb in network.links.index[
+            ~network.links[f"bus{i}"].isna() & (network.links[f"bus{i}"] != "")
+        ]:
+            bus = network.links.at[cb, f"bus{i}"]
             for sn in snapshots:
                 network._p_balance[bus, sn].variables.append(
                     (efficiency.at[sn, cb], network.model.link_p[cb, sn])
@@ -1943,7 +1941,7 @@ def extract_optimisation_results(
             "Bus": ["p", "v_ang", "v_mag_pu", "marginal_price"],
             "Line": ["p0", "p1", "mu_lower", "mu_upper"],
             "Transformer": ["p0", "p1", "mu_lower", "mu_upper"],
-            "Link": ["p" + col[3:] for col in network.links.columns if col[:3] == "bus"]
+            "Link": [f"p{i}" for i in ["0", "1"] + additional_linkports(network)]
             + ["mu_lower", "mu_upper"],
         },
     )
@@ -2075,16 +2073,10 @@ def extract_optimisation_results(
         )
 
         # Add any other buses to which the links are attached
-        for i in [
-            int(col[3:])
-            for col in network.links.columns
-            if col[:3] == "bus" and col not in ["bus0", "bus1"]
-        ]:
-            efficiency = get_as_dense(
-                network, "Link", "efficiency{}".format(i), snapshots
-            )
-            p_name = "p{}".format(i)
-            links = network.links.index[network.links["bus{}".format(i)] != ""]
+        for i in additional_linkports(network):
+            efficiency = get_as_dense(network, "Link", f"efficiency{i}", snapshots)
+            p_name = f"p{i}"
+            links = network.links.index[network.links[f"bus{i}"] != ""]
             network.links_t[p_name].loc[snapshots, links] = (
                 -network.links_t.p0.loc[snapshots, links]
                 * efficiency.loc[snapshots, links]
@@ -2092,7 +2084,7 @@ def extract_optimisation_results(
             network.buses_t.p.loc[snapshots] -= (
                 network.links_t[p_name]
                 .loc[snapshots, links]
-                .groupby(network.links["bus{}".format(i)], axis=1)
+                .groupby(network.links[f"bus{i}"], axis=1)
                 .sum()
                 .reindex(columns=network.buses_t.p.columns, fill_value=0.0)
             )
diff --git a/pypsa/pf.py b/pypsa/pf.py
index 5f79dc00..c0097d4e 100644
--- a/pypsa/pf.py
+++ b/pypsa/pf.py
@@ -30,9 +30,14 @@ from scipy.sparse import issparse
 from scipy.sparse import vstack as svstack
 from scipy.sparse.linalg import spsolve
 
-from pypsa.descriptors import Dict, allocate_series_dataframes, degree
+from pypsa.descriptors import (
+    Dict,
+    additional_linkports,
+    allocate_series_dataframes,
+    degree,
+)
 from pypsa.descriptors import get_switchable_as_dense as get_as_dense
-from pypsa.descriptors import zsum
+from pypsa.descriptors import update_linkports_component_attrs, zsum
 
 pd.Series.zsum = zsum
 
@@ -135,7 +140,7 @@ def _network_prepare_and_run_pf(
     linear=False,
     distribute_slack=False,
     slack_weights="p_set",
-    **kwargs
+    **kwargs,
 ):
     if linear:
         sub_network_pf_fun = sub_network_lpf
@@ -155,15 +160,11 @@ def _network_prepare_and_run_pf(
     if not network.links.empty:
         p_set = get_as_dense(network, "Link", "p_set", snapshots)
         network.links_t.p0.loc[snapshots] = p_set.loc[snapshots]
-        for i in [
-            int(col[3:])
-            for col in network.links.columns
-            if col[:3] == "bus" and col != "bus0"
-        ]:
-            eff_name = "efficiency" if i == 1 else "efficiency{}".format(i)
+        for i in ["1"] + additional_linkports(network):
+            eff_name = "efficiency" if i == "1" else f"efficiency{i}"
             efficiency = get_as_dense(network, "Link", eff_name, snapshots)
-            links = network.links.index[network.links["bus{}".format(i)] != ""]
-            network.links_t["p{}".format(i)].loc[snapshots, links] = (
+            links = network.links.index[network.links[f"bus{i}"] != ""]
+            network.links_t[f"p{i}"].loc[snapshots, links] = (
                 -network.links_t.p0.loc[snapshots, links]
                 * efficiency.loc[snapshots, links]
             )
@@ -214,7 +215,7 @@ def _network_prepare_and_run_pf(
                     skip_pre=True,
                     distribute_slack=distribute_slack,
                     slack_weights=sn_slack_weights,
-                    **kwargs
+                    **kwargs,
                 )
         else:
             sub_network_pf_fun(
@@ -1051,6 +1052,8 @@ def calculate_dependent_values(network):
         network.stores.carrier == "", "carrier"
     ] = network.stores.bus.map(network.buses.carrier)
 
+    update_linkports_component_attrs(network)
+
 
 def find_slack_bus(sub_network):
     """


Test patch:
diff --git a/test/test_lopf_multilink.py b/test/test_lopf_multilink.py
index 8cc71397..3409d851 100644
--- a/test/test_lopf_multilink.py
+++ b/test/test_lopf_multilink.py
@@ -8,53 +8,7 @@ import pypsa
 
 @pytest.fixture
 def network():
-    override_component_attrs = pypsa.descriptors.Dict(
-        {k: v.copy() for k, v in pypsa.components.component_attrs.items()}
-    )
-    override_component_attrs["Link"].loc["bus2"] = [
-        "string",
-        np.nan,
-        np.nan,
-        "2nd bus",
-        "Input (optional)",
-    ]
-    override_component_attrs["Link"].loc["bus3"] = [
-        "string",
-        np.nan,
-        np.nan,
-        "3rd bus",
-        "Input (optional)",
-    ]
-    override_component_attrs["Link"].loc["efficiency2"] = [
-        "static or series",
-        "per unit",
-        1.0,
-        "2nd bus efficiency",
-        "Input (optional)",
-    ]
-    override_component_attrs["Link"].loc["efficiency3"] = [
-        "static or series",
-        "per unit",
-        1.0,
-        "3rd bus efficiency",
-        "Input (optional)",
-    ]
-    override_component_attrs["Link"].loc["p2"] = [
-        "series",
-        "MW",
-        0.0,
-        "2nd bus output",
-        "Output",
-    ]
-    override_component_attrs["Link"].loc["p3"] = [
-        "series",
-        "MW",
-        0.0,
-        "3rd bus output",
-        "Output",
-    ]
-
-    n = pypsa.Network(override_component_attrs=override_component_attrs)
+    n = pypsa.Network()
     n.set_snapshots(range(10))
 
     n.add("Bus", "bus")