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})."
)
|