Transport Delay¶
Multiport components as Links and Processes can model time-delayed energy transport via the delay[x] and cyclic_delay[x] attributes where [x] represents the number of the port. This is useful for representing transport delays in pipelines, shipping and other transport modes, or any process where energy withdrawn at bus0 arrives at output ports after a configurable time lag.
Unlike links, processes use the convention to enumerate all delay and cyclic_delay attributes, i.e. delay0, delay1, ..., cyclic_delay0, ... Therefore they allows to have a delay at bus0 in reference to the operation of the process p. Links on the other hand follow the convention on always refering to p at bus0. The delay attributes star with delay affecting p1 at bus1 and continue with delay2. This mirrors the convention of the efficiency attribute.
So how do the attributes work?
delay: The delay in units of elapsed time, measured against cumulativen.snapshot_weightings.generators. Energy withdrawn frombus0at snapshottarrives atbus1at the first snapshot where the cumulative weighting sincetreaches at leastdelay. For example, with uniform hourly snapshots (weightings = 1),delay=3means a 3-hour delay. With 3-hourly snapshots (weightings = 3), the samedelay=3shifts delivery by one snapshot.cyclic_delay: IfTrue(default), energy wraps cyclically from the end of the optimization horizon back to the start — energy sent in the last snapshots arrives at the first snapshots. IfFalse, energy is lost at the tail of the horizon and the first snapshots receive nothing from delayed links.Multi-port delays: Each output port can have its own delay. For additional ports (
bus2,bus3, ...), usedelay2,delay3, ... andcyclic_delay2,cyclic_delay3, ....
This deep-dive example demonstrates time-delayed energy transport through Link components. Note that the exact same behavior could be reproduced by using the Process component aligning the attributes as described above. For a more basic introduction to delays in the context of chained reservoirs, see the Chained Hydro-Reservoirs example.
Setup¶
We set up a simple two-bus system: a production site with cheap generation connected to a demand site via a delayed link representing a hydrogen pipeline.
import matplotlib.pyplot as plt
import pandas as pd
import pypsa
n = pypsa.Network()
n.set_snapshots(range(8))
n.add("Bus", "production")
n.add("Bus", "demand")
n.add("Generator", "wind", bus="production", p_nom=100, marginal_cost=5)
n.add("Generator", "backup", bus="demand", p_nom=100, marginal_cost=80)
n.add("Load", "load", bus="demand", p_set=[10, 30, 50, 20, 40, 60, 25, 15])
n.sanitize()
Cyclic Delay¶
With cyclic_delay=True (the default), energy wraps around from the end of the optimization horizon to the start. The link has a 2-snapshot delivery delay and 95% efficiency.
n.add(
"Link",
"pipeline",
bus0="production",
bus1="demand",
p_nom=100,
efficiency=0.95,
delay=2,
cyclic_delay=True,
)
n.optimize();
The output power p1 is a time-shifted version of the input p0, scaled by the efficiency.
fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)
colors = [f"C{i}" for i in range(8)]
delay = 2
p0_colors = colors
p1_colors = colors[-delay:] + colors[:-delay]
n.links_t.p0["pipeline"].plot.bar(
ax=axes[0], title="Input (p0 at production)", color=p0_colors
)
(-n.links_t.p1["pipeline"]).plot.bar(
ax=axes[1], title="Output (-p1 at demand)", color=p1_colors
)
for ax in axes:
ax.set_ylabel("MW")
plt.tight_layout()
Non-Cyclic Delay¶
With cyclic_delay=False, the first snapshots receive no flow from the link (nothing was sent early enough to arrive), and energy sent in the last snapshots is lost (it would arrive beyond the horizon).
n.links.loc["pipeline", "cyclic_delay"] = False
n.optimize();
fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)
colors = [f"C{i}" for i in range(8)]
delay = 2
gray = "lightgray"
p0_colors = colors[: len(colors) - delay] + [gray] * delay
p1_colors = [gray] * delay + colors[: len(colors) - delay]
n.links_t.p0["pipeline"].plot.bar(
ax=axes[0], title="Input (p0 at production)", color=p0_colors
)
(-n.links_t.p1["pipeline"]).plot.bar(
ax=axes[1], title="Output (-p1 at demand)", color=p1_colors
)
for ax in axes:
ax.set_ylabel("MW")
plt.tight_layout()
Notice that p1 is zero for the first 2 snapshots and p0 drops to zero for the last 2 snapshots.
Multi-Port Delay¶
Each output port can have its own delay. Here we add a third bus representing a branch pipeline with a longer delay.
n.remove("Link", "pipeline")
n.add("Bus", "demand2")
n.add("Generator", "backup2", bus="demand2", p_nom=100, marginal_cost=80)
n.add("Load", "load2", bus="demand2", p_set=[5, 15, 25, 10, 20, 30, 12, 8])
n.add(
"Link",
"pipeline",
bus0="production",
bus1="demand",
bus2="demand2",
p_nom=100,
efficiency=0.95,
efficiency2=0.90,
delay=2,
delay2=4,
cyclic_delay=True,
cyclic_delay2=True,
)
n.optimize();
fig, axes = plt.subplots(1, 3, figsize=(15, 4), sharey=True)
colors = [f"C{i}" for i in range(8)]
p0_colors = colors
p1_colors = colors[-2:] + colors[:-2]
p2_colors = colors[-4:] + colors[:-4]
n.links_t.p0["pipeline"].plot.bar(ax=axes[0], title="Input (p0)", color=p0_colors)
(-n.links_t.p1["pipeline"]).plot.bar(
ax=axes[1], title="Output bus1 (-p1, delay=2)", color=p1_colors
)
(-n.links_t.p2["pipeline"]).plot.bar(
ax=axes[2], title="Output bus2 (-p2, delay=4)", color=p2_colors
)
for ax in axes:
ax.set_ylabel("MW")
plt.tight_layout()
Non-Uniform Snapshot Weightings¶
The delay is interpreted in snapshot_weightings.generators time units, not snapshot counts. With non-uniform weightings, the same delay value can shift by different numbers of snapshots.
n.remove("Link", "pipeline")
n.remove("Bus", "demand2")
n.remove("Generator", "backup2")
n.remove("Load", "load2")
n.snapshot_weightings.loc[:, "generators"] = [1, 2, 1, 2, 1, 2, 1, 2]
n.add(
"Link",
"pipeline",
bus0="production",
bus1="demand",
p_nom=100,
efficiency=0.95,
delay=3,
cyclic_delay=True,
)
n.optimize();
pd.DataFrame(
{
"weighting": n.snapshot_weightings.generators,
"p0": n.links_t.p0["pipeline"],
"p1": n.links_t.p1["pipeline"],
}
)
from pypsa.components._types.links import Links
src, _ = Links.get_delay_source_indexer(
n.snapshots, n.snapshot_weightings.generators, delay=3, is_cyclic=True
)
fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)
colors = [f"C{i}" for i in range(8)]
p1_colors = [colors[s] for s in src]
n.links_t.p0["pipeline"].plot.bar(
ax=axes[0], title="Input (p0 at production)", color=colors
)
(-n.links_t.p1["pipeline"]).plot.bar(
ax=axes[1], title="Output (-p1 at demand)", color=p1_colors
)
for ax in axes:
ax.set_ylabel("MW")
plt.tight_layout()
With non-uniform weightings [1, 2, 1, 2, 1, 2, 1, 2] and delay=3, the source-snapshot mapping depends on cumulative weighting sums rather than simple index arithmetic. Here, two snapshots with weights 1+2=3 exactly cover the delay, resulting in a 2-snapshot shift despite the delay value being 3.
Sub-Snapshot Delays¶
When the delay is shorter than a single snapshot's weighting, the shift still rounds up to one snapshot. Here we use uniform weightings of 4 time units per snapshot with a delay of only 3. Since no snapshot boundary falls within the 3-unit window, the output is shifted by exactly one snapshot — the delay is effectively absorbed within the snapshot duration.
n.snapshot_weightings.loc[:, "generators"] = 4
n.links.loc["pipeline", "delay"] = 3
n.optimize();
src, _ = Links.get_delay_source_indexer(
n.snapshots, n.snapshot_weightings.generators, delay=3, is_cyclic=True
)
pd.DataFrame(
{
"weighting": n.snapshot_weightings.generators,
"p0": n.links_t.p0["pipeline"],
"p1": n.links_t.p1["pipeline"],
"source snapshot": src,
}
)
fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)
colors = [f"C{i}" for i in range(8)]
p1_colors = [colors[s] for s in src]
n.links_t.p0["pipeline"].plot.bar(
ax=axes[0], title="Input (p0 at production)", color=colors
)
(-n.links_t.p1["pipeline"]).plot.bar(
ax=axes[1], title="Output (-p1 at demand)", color=p1_colors
)
for ax in axes:
ax.set_ylabel("MW")
plt.tight_layout()
The source-snapshot column confirms that each output snapshot draws from exactly one snapshot earlier (shift of 1), even though delay=3. With weighting=4, the cumulative snapshot starts are [0, 4, 8, ...] — the 3-unit delay never crosses more than one snapshot boundary, so the mapping s(t) = t-1 (cyclically) applies uniformly. Compare this with the previous section where delay=3 with mixed weightings [1, 2, ...] resulted in a 2-snapshot shift. See the user guide for details on how the source-snapshot mapping is computed.
Super-Snapshot Delays¶
Conversely, when the delay exceeds a single snapshot's weighting, the shift spans multiple snapshots. Here we keep weighting=4 but increase the delay to 5. The delay now crosses one full snapshot boundary, resulting in a 2-snapshot shift.
n.links.loc["pipeline", "delay"] = 5
n.optimize();
src, _ = Links.get_delay_source_indexer(
n.snapshots, n.snapshot_weightings.generators, delay=5, is_cyclic=True
)
pd.DataFrame(
{
"weighting": n.snapshot_weightings.generators,
"p0": n.links_t.p0["pipeline"],
"p1": n.links_t.p1["pipeline"],
"source snapshot": src,
}
)
fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)
colors = [f"C{i}" for i in range(8)]
p1_colors = [colors[s] for s in src]
n.links_t.p0["pipeline"].plot.bar(
ax=axes[0], title="Input (p0 at production)", color=colors
)
(-n.links_t.p1["pipeline"]).plot.bar(
ax=axes[1], title="Output (-p1 at demand)", color=p1_colors
)
for ax in axes:
ax.set_ylabel("MW")
plt.tight_layout()
With delay=5 and weighting=4, each snapshot spans 4 time units, so a 5-unit delay crosses one full snapshot plus 1 unit into the next — yielding a 2-snapshot shift (s(t) = t-2). Compare this with the sub-snapshot case above where delay=3 < weighting=4 produced only a 1-snapshot shift. The general rule: the number of snapshot shifts equals ceil(delay / weighting) for uniform weightings.