Skip to content

electricity.py

Enforce absolute NTC floor values on cross-border transmission corridors.

For each corridor listed in the TYNDP transmission trajectories CSV, the module filters to the current planning horizon year and computes the NTC target as max(direct_capacity, indirect_capacity). It then calculates the installed capacity of every Line and Link on the corridor, weighting each component by its s_max_pu / p_max_pu to obtain the effective active transfer limit. Installed capacity is summed over both extendable components (contributing their current lower bound s_nom_min / p_nom_min) and non-extendable components (contributing their fixed s_nom / p_nom).

The shortfall target = NTC_target − installed_cap is derived. When the shortfall is positive (NTC not yet met), every extendable component on the corridor has its lower bound scaled upward proportionally so that the summed extendable lower bounds increase by exactly the shortfall. When no extendable capacity exists on a corridor, the raw lower bound per component is target / Σ(s_max_pu) for AC lines or target / Σ(p_max_pu) for DC forward and reverse links respectively, so that the sum of effective capacities equals the shortfall.

apply_tyndp_transmission_lower_bounds(n, snakemake)

Set s_nom_min / p_nom_min so corridors meet the absolute TYNDP NTC floor.

For each corridor in the TYNDP trajectories CSV the function filters to the current planning horizon year, computes installed_cap — the sum of all extendable and non-extendable Line/Link capacities on the corridor weighted by s_max_pu / p_max_pu — and derives the shortfall target = NTC_target − installed_cap. When the shortfall is positive, the extendable lower bounds are scaled proportionally by cap_factor = (total_ext_cap + target) / total_ext_cap so that their sum increases by exactly the shortfall.

Parameters:

Name Type Description Default
n pypsa.Network

Pre-solve network to be modified in place.

required
snakemake snakemake.script.Snakemake

Snakemake workflow object. Required attributes:

  • snakemake.input.tyndp_transmission_trajectories — path to a CSV with columns from_node, to_node, direct_capacity, indirect_capacity, year. The effective per-corridor NTC target is max(direct_capacity, indirect_capacity) — the larger of the two direction capacities is used intentionally to capture the dominant transfer direction.
  • snakemake.wildcards.planning_horizons — 4-digit year string (e.g. "2040").
required

Returns:

Type Description
None

Modifies n.lines["s_nom_min"] and n.links["p_nom_min"] in place for extendable components on under-capacity corridors.

Raises:

Type Description
ValueError

If cross-border DC Links are not modelled as bidirectional pairs.

ValueError

If forward and reverse DC link capacities on a corridor are asymmetric.

Notes
  • The guard for which planning horizons trigger this function is enforced by the caller in mods.network.__init__.
  • installed_cap sums both extendable components (using s_nom_min / p_nom_min as their current lower bound) and non-extendable components (using fixed s_nom / p_nom), each weighted by s_max_pu / p_max_pu. Non-extendable components are never modified.
  • Only the forward DC direction is counted for installed_cap; symmetry is validated by the asymmetry check before the corridor loop.
  • Corridors where target ≤ 0 are skipped (NTC already met) and logged at INFO level.
  • The proportional scaling cap_factor = (total_ext_cap + target) / total_ext_cap preserves the relative distribution of extendable capacity across parallel components on the same corridor.
  • When extendable capacity is zero (ac_cap_ext == 0 and dc_cap_ext == 0), the raw lower bound per component is target / Σ(s_max_pu) for AC lines and target / Σ(p_max_pu) for DC forward and reverse links respectively, ensuring that the sum of effective capacities equals the shortfall. A ValueError is raised if the relevant s_max_pu or p_max_pu sum is zero.
Source code in mods/network/electricity.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
def apply_tyndp_transmission_lower_bounds(
    n: pypsa.Network,
    snakemake: Snakemake,
) -> None:
    """
    Set ``s_nom_min`` / ``p_nom_min`` so corridors meet the absolute TYNDP NTC floor.

    For each corridor in the TYNDP trajectories CSV the function filters to the
    current planning horizon year, computes ``installed_cap`` — the sum of all
    extendable and non-extendable Line/Link capacities on the corridor weighted
    by ``s_max_pu`` / ``p_max_pu`` — and derives the shortfall
    ``target = NTC_target − installed_cap``.  When the shortfall is positive,
    the extendable lower bounds are scaled proportionally by
    ``cap_factor = (total_ext_cap + target) / total_ext_cap`` so that their sum
    increases by exactly the shortfall.

    Parameters
    ----------
    n : pypsa.Network
        Pre-solve network to be modified in place.
    snakemake : Snakemake
        Snakemake workflow object.  Required attributes:

        * ``snakemake.input.tyndp_transmission_trajectories`` — path to a CSV
          with columns ``from_node``, ``to_node``, ``direct_capacity``,
          ``indirect_capacity``, ``year``.  The effective per-corridor NTC target
          is ``max(direct_capacity, indirect_capacity)`` — the larger of the two
          direction capacities is used intentionally to capture the dominant
          transfer direction.
        * ``snakemake.wildcards.planning_horizons`` — 4-digit year string
          (e.g. ``"2040"``).

    Returns
    -------
    None
        Modifies ``n.lines["s_nom_min"]`` and ``n.links["p_nom_min"]`` in place
        for extendable components on under-capacity corridors.

    Raises
    ------
    ValueError
        If cross-border DC Links are not modelled as bidirectional pairs.
    ValueError
        If forward and reverse DC link capacities on a corridor are asymmetric.

    Notes
    -----
    * The guard for which planning horizons trigger this function is enforced
      by the caller in ``mods.network.__init__``.
    * ``installed_cap`` sums both extendable components (using ``s_nom_min`` /
      ``p_nom_min`` as their current lower bound) and non-extendable components
      (using fixed ``s_nom`` / ``p_nom``), each weighted by ``s_max_pu`` /
      ``p_max_pu``.  Non-extendable components are never modified.
    * Only the forward DC direction is counted for ``installed_cap``; symmetry
      is validated by the asymmetry check before the corridor loop.
    * Corridors where ``target ≤ 0`` are skipped (NTC already met) and logged
      at INFO level.
    * The proportional scaling ``cap_factor = (total_ext_cap + target) / total_ext_cap``
      preserves the relative distribution of extendable capacity across parallel
      components on the same corridor.
    * When extendable capacity is zero (``ac_cap_ext == 0`` and ``dc_cap_ext == 0``),
      the raw lower bound per component is ``target / Σ(s_max_pu)`` for AC lines and
      ``target / Σ(p_max_pu)`` for DC forward and reverse links respectively, ensuring
      that the sum of effective capacities equals the shortfall.  A ``ValueError`` is
      raised if the relevant ``s_max_pu`` or ``p_max_pu`` sum is zero.
    """
    pyear = int(snakemake.wildcards.planning_horizons)
    if pyear not in snakemake.config["mods"]["tyndp_lower_bounds"]["years"]:
        return

    tyndp_traj = pd.read_csv(snakemake.input.tyndp_transmission_trajectories)

    # max(direct_capacity, indirect_capacity) is used as the effective corridor
    # NTC target — the larger of the two direction capacities captures the dominant
    # transfer direction and is intentional per the algorithm design.
    df = (
        tyndp_traj[tyndp_traj["year"] == pyear]
        .set_index(["from_node", "to_node"])
        .drop(columns=["year"])
        .max(axis=1)
        .rename("NTC_target")
        .reset_index()
    )

    relevant_links_curr, relevant_lines_curr = get_relevant_links_and_lines(n)
    sanity_check_links_and_lines(
        relevant_links=relevant_links_curr,
        relevant_lines=relevant_lines_curr,
        tyndp_transmission=df,
    )
    _validate_dc_link_symmetry(relevant_links_curr)

    for row in df.itertuples():
        node_from = row.from_node
        node_to = row.to_node
        from_to = f"{node_from}{node_to}"

        corridor = _classify_corridor(
            relevant_lines_curr, relevant_links_curr, node_from, node_to
        )

        # Corridors present in the TYNDP CSV but entirely absent from the
        # (clustered) network are tolerated.
        if all(c.empty for c in corridor.components):
            logger.info(f"Corridor {from_to}: not modelled in network — skipping.")
            continue

        # Installed capacity: non-extendable components contribute fixed s_nom/p_nom;
        # extendable components contribute their current lower bound (s_nom_min/p_nom_min).
        # s_max_pu / p_max_pu weight converts apparent/rated capacity to effective active transfer limit.
        ac_cap_nonext = (
            corridor.ac_nonext["s_nom"] * corridor.ac_nonext["s_max_pu"]
        ).sum()
        ac_cap_ext = (corridor.ac_ext["s_nom_min"] * corridor.ac_ext["s_max_pu"]).sum()
        dc_cap_nonext = (
            corridor.dc_nonext["p_nom"] * corridor.dc_nonext["p_max_pu"]
        ).sum()
        dc_cap_ext = (corridor.dc_ext["p_nom_min"] * corridor.dc_ext["p_max_pu"]).sum()

        installed_cap = ac_cap_nonext + ac_cap_ext + dc_cap_nonext + dc_cap_ext

        target = row.NTC_target - installed_cap

        if target <= 0:
            logger.info(
                f"Corridor {from_to}: TYNDP target already met "
                f"(target={target:.1f} MW ≤ 0) — skipping."
            )
            continue

        if ac_cap_ext == 0 and dc_cap_ext == 0:
            # Zero-cap branch: extendable lower bounds are all zero; target is distributed so
            # that the total (summed) effective capacity equals the shortfall.
            if not corridor.ac_ext.empty:
                n.lines.loc[corridor.ac_ext.index, "s_nom_min"] = (
                    target / n.lines.loc[corridor.ac_ext.index, "s_max_pu"].sum()
                )
            elif not corridor.dc_ext.empty:
                n.links.loc[corridor.dc_ext.index, "p_nom_min"] = (
                    target / n.links.loc[corridor.dc_ext.index, "p_max_pu"].sum()
                )
                n.links.loc[corridor.dc_indir_ext.index, "p_nom_min"] = (
                    target / n.links.loc[corridor.dc_indir_ext.index, "p_max_pu"].sum()
                )
            else:
                # The corridor is modelled (non-extendable components exist) but
                # has no extendable capacity to scale up, so the NTC floor cannot
                # be met. This is a genuine inconsistency — fail fast.
                raise ValueError(
                    f"Corridor {from_to}: target={target:.1f} MW shortfall but "
                    f"only non-extendable lines or links found — cannot raise the "
                    f"lower bound to meet the TYNDP NTC floor."
                )
            logger.info(
                f"Corridor {from_to}: lower bounds set (zero-cap) — shortfall={target:.1f} MW."
            )
        else:
            total_ext_cap = ac_cap_ext + dc_cap_ext
            cap_factor = (total_ext_cap + target) / total_ext_cap

            n.lines.loc[corridor.ac_ext.index, "s_nom_min"] *= cap_factor
            n.links.loc[corridor.dc_ext.index, "p_nom_min"] *= cap_factor
            n.links.loc[corridor.dc_indir_ext.index, "p_nom_min"] *= cap_factor

            logger.info(
                f"Corridor {from_to}: lower bounds scaled — shortfall={target:.1f} MW "
                f"(cap_factor={cap_factor:.3f})."
            )