Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lossy Bidirectional Links #1192

Merged
merged 30 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ea57cea
Lossy Bidirectional Links
Eric-Nitschke Nov 13, 2024
9acbfe4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 14, 2024
a1f91e4
Update release notes
Eric-Nitschke Nov 18, 2024
31c7973
Merge remote-tracking branch 'origin/main'
Eric-Nitschke Nov 18, 2024
a0c0a05
Spelling fix
Eric-Nitschke Nov 22, 2024
a2150d6
Merge branch 'main' of github.com:pypsa-meets-earth/pypsa-earth
Eric-Nitschke Nov 26, 2024
1a98c9f
docs(contributor): contrib-readme-action has updated readme
github-actions[bot] Nov 26, 2024
ac90aec
Revert "docs(contributor): contrib-readme-action has updated readme"
Eric-Nitschke Dec 19, 2024
a3bd91d
docs(contributor): contrib-readme-action has updated readme
github-actions[bot] Dec 19, 2024
80aee27
Fix ci (#1210)
davide-f Nov 28, 2024
45fdc2b
Merge branch 'main' of github.com:Eric-Nitschke/pypsa-earth-bidirecti…
Eric-Nitschke Dec 19, 2024
77f557f
git@github.com:pypsa-meets-earth/pypsa-earth.git
davide-f Nov 28, 2024
e38b221
Fix bidirectional lossy links
Eric-Nitschke Jan 2, 2025
fefe70b
Constraint implementation bug fixes
Eric-Nitschke Jan 2, 2025
4738a3f
Revert "Merge branch 'main' of github.com:Eric-Nitschke/pypsa-earth-b…
Eric-Nitschke Jan 2, 2025
e108be5
Merge branch 'lossy_length_based'
Eric-Nitschke Jan 2, 2025
9f55920
Merge remote-tracking branch 'upstream/main'
Eric-Nitschke Jan 2, 2025
2ac8db4
Unify transmission efficiency
Eric-Nitschke Jan 2, 2025
02fc071
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 2, 2025
9d14ac2
Spelling fix
Eric-Nitschke Jan 2, 2025
f6aa253
Merge branch 'main' of github.com:Eric-Nitschke/pypsa-earth-bidirecti…
Eric-Nitschke Jan 2, 2025
ca1db0a
Release notes update
Eric-Nitschke Jan 2, 2025
7c87a48
Bugfix Snakefile for non-Windows operating systems
Eric-Nitschke Jan 13, 2025
4092176
Merge branch 'main' into main
Eric-Nitschke Jan 13, 2025
fcb9911
Bugfix test config
Eric-Nitschke Jan 13, 2025
007e427
Merge branch 'main' of github.com:Eric-Nitschke/pypsa-earth-bidirecti…
Eric-Nitschke Jan 13, 2025
b1ef404
Merge branch 'main' into main
Eric-Nitschke Jan 14, 2025
090eeb9
Final adjustments for bidirectional lossy links
Eric-Nitschke Jan 14, 2025
d84ce66
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 14, 2025
eaba5cf
Update Snakefile: Remove os.getcwd from this PR. To be solved in Bugf…
Eddy-JV Jan 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions Snakefile
Original file line number Diff line number Diff line change
Expand Up @@ -732,7 +732,10 @@ if config["augmented_line_connection"].get("add_to_snakefile", False) == False:


rule add_extra_components:
params:
transmission_efficiency=config["sector"]["transmission_efficiency"],
input:
overrides="data/override_component_attrs",
network="networks/" + RDIR + "elec_s{simpl}_{clusters}.nc",
tech_costs=COSTS,
output:
Expand Down Expand Up @@ -806,6 +809,12 @@ if config["monte_carlo"]["options"].get("add_to_snakefile", False) == False:
solving=config["solving"],
augmented_line_connection=config["augmented_line_connection"],
input:
overrides=(
os.getcwd() + "/data/override_component_attrs"
if os.name == "nt"
else "data/override_component_attrs"
),
# on Windows os.getcwd() is required because of the "copy-minimal" shadow directory
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be sure I get this point properly: is shadow directory the one which is created by the solver to store the temporary files? I wonder if this behaviour may also depend on a solver...

Regarding the files management, it has been found previously that os.getcwd( ) may lead to some troubles if pypsa-earth model is being used as snakemake submodule. In particular #1137 has fixed this replacing os.getcwd( ) by a custom directory path. May it be a way to go also in our case?

Copy link
Contributor Author

@Eric-Nitschke Eric-Nitschke Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In short:
The shadow directory is a Snakemake thing and not solver related.

With the "shadow" rule Snakemake creates an isolated temporary directory, in which it runs the corresponding rule (in this case "solve_network"). With shadow: "shallow", Snakemake symlinks all top level files and directory so that all relative file paths provided will still reach the right directories. However with shadow: "copy-minimal", the inputs of the rule are copied instead of symlinked. In my tests this led to Snakemake not having access to the files within the "data/override_component_attrs" folder. My guess is, that copy minimal only copies files directly, but not the content of directories. PR #790 is the reason, why "copy-minimal" is needed on Windows.

You can check the Snakemake documentation for more details, though it is not that extensive.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, I think I found the issue:
else "/data/override_component_attrs" should be
else "data/override_component_attrs"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally, itmight be nice to move away from os.getcwd() to the changes in #1137. However, I am not confident in implementing these changes myself.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davide-f This definitely needs your attention as you already made PR #790. And @Eric-Nitschke if this approved by Davide, then probably the solution should be done for all Rules that use the solve_network.py script.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.getcwd() must be avoided as it does not track the use of submodules and can lead to issues.
Have you tried with directory(data/...)? [although the directory command should be optional for inputs].

Moreover, not sure if copy-minimal is strictly needed here; have you tested?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davide-f I've tried running it on Windows with "shallow" instead, which led to issues with admin rights similar to PR #790.

I've not tried directory(data/...) yet. Would that look something like this?

input:
            overrides=(
                directory("/data/override_component_attrs")
                if os.name == "nt"
                else "/data/override_component_attrs"
            ),

Removing the different handling (as done by @Eddy-JV) will most likely mean, that the code will not work properly on Windows since it will not find the proper files when running with "copy-minimal" (an issue that will not be solved by PR #1295).
However, I'm in favor of doing it this way, if it means that we'll merge this PR somewhat soon and create a new issue for the Windows issues.

network="networks/" + RDIR + "elec_s{simpl}_{clusters}_ec_l{ll}_{opts}.nc",
output:
"results/" + RDIR + "networks/elec_s{simpl}_{clusters}_ec_l{ll}_{opts}.nc",
Expand Down Expand Up @@ -872,6 +881,12 @@ if config["monte_carlo"]["options"].get("add_to_snakefile", False) == True:
solving=config["solving"],
augmented_line_connection=config["augmented_line_connection"],
input:
overrides=(
os.getcwd() + "/data/override_component_attrs"
if os.name == "nt"
else "data/override_component_attrs"
),
# on Windows os.getcwd() is required because of the "copy-minimal" shadow directory
network="networks/"
+ RDIR
+ "elec_s{simpl}_{clusters}_ec_l{ll}_{opts}_{unc}.nc",
Expand Down Expand Up @@ -1615,7 +1630,12 @@ if config["foresight"] == "overnight":
solving=config["solving"],
augmented_line_connection=config["augmented_line_connection"],
input:
overrides="data/override_component_attrs",
overrides=(
os.getcwd() + "/data/override_component_attrs"
if os.name == "nt"
else "data/override_component_attrs"
),
# on Windows os.getcwd() is required because of the "copy-minimal" shadow directory
# network=RESDIR
# + "prenetworks/elec_s{simpl}_{clusters}_ec_l{ll}_{opts}_{sopts}_{planning_horizons}_{discountrate}.nc",
network=RESDIR
Expand Down Expand Up @@ -2081,7 +2101,12 @@ if config["foresight"] == "myopic":
"co2_sequestration_potential", 200
),
input:
overrides="data/override_component_attrs",
overrides=(
os.getcwd() + "/data/override_component_attrs"
if os.name == "nt"
else "data/override_component_attrs"
),
# on Windows os.getcwd() is required because of the "copy-minimal" shadow directory
network=RESDIR
+ "prenetworks-brownfield/elec_s{simpl}_{clusters}_l{ll}_{opts}_{sopts}_{planning_horizons}_{discountrate}_{demand}_{h2export}export.nc",
costs=CDIR + "costs_{planning_horizons}.csv",
Expand Down
4 changes: 3 additions & 1 deletion config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,9 @@ sector:
transmission_efficiency:
electricity distribution grid:
efficiency_static: 0.97 # efficiency of distribution grid (i.e. 3% loses)
H2 pipeline:
efficiency_per_1000km: 1
compression_per_1000km: 0.017 # DEA technology data. Mean of Energy losses, lines 5000-20000 MW and lines >20000 MW for 2020, 2030 and 2050, [%/1000 km]

dynamic_transport:
enable: false # If "True", then the BEV and FCEV shares are obtained depending on the "Co2L"-wildcard (e.g. "Co2L0.70: 0.10"). If "False", then the shares are obtained depending on the "demand" wildcard and "planning_horizons" wildcard as listed below (e.g. "DF_2050: 0.08")
Expand Down Expand Up @@ -695,7 +698,6 @@ sector:
biomass: biomass
keep_existing_capacities: true


solving:
options:
formulation: kirchhoff
Expand Down
4 changes: 4 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ This part of documentation collects descriptive release notes to capture the mai

* In alternative clustering, generate hydro inflows by shape and avoid hydro inflows duplication for plants installed in the same node `PR #1120 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1120>`

* Add a function to calculate length-based efficiencies and apply it to the H2 pipelines. `PR #1192 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1192>`__

**Minor Changes and bug-fixing**

* Prevent computation of powerplantmatching if replace option is selected for custom_powerplants `PR #1281 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1281>`__
Expand All @@ -25,6 +27,8 @@ This part of documentation collects descriptive release notes to capture the mai

* Fix readthedocs by explicitly specifying the location of the Sphinx config `PR #1292 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1292>`__

* Fix lossy bidirectional links, especially H2 pipelines, which would sometimes gain H2 instead of losing it. `PR #1192 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1192>`__

PyPSA-Earth 0.6.0
=================

Expand Down
88 changes: 88 additions & 0 deletions scripts/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1483,3 +1483,91 @@ def safe_divide(numerator, denominator, default_value=np.nan):
f"Division by zero: {numerator} / {denominator}, returning NaN."
)
return np.nan


def lossy_bidirectional_links(n, carrier):
"""
Split bidirectional links of type carrier into two unidirectional links to include transmission losses.
"""

# identify all links of type carrier
carrier_i = n.links.query("carrier == @carrier").index

if carrier_i.empty:
return

logger.info(f"Splitting bidirectional links with the carrier {carrier}")

# set original links to be unidirectional
n.links.loc[carrier_i, "p_min_pu"] = 0

# add a new links that mirror the original links, but represent the reversed flow direction
# the new links have a cost and length of 0 to not distort the overall cost and network length
rev_links = (
n.links.loc[carrier_i].copy().rename({"bus0": "bus1", "bus1": "bus0"}, axis=1)
)
rev_links["length_original"] = rev_links[
"length"
] # tracker for the length of the original links length
rev_links["capital_cost"] = 0
rev_links["length"] = 0
rev_links["reversed"] = True # tracker for easy identification of reversed links
rev_links.index = rev_links.index.map(lambda x: x + "-reversed")

# add the new reversed links to the network and fill the newly created trackers with default values for the other links
n.links = pd.concat([n.links, rev_links], sort=False)
n.links["reversed"] = n.links["reversed"].fillna(False).infer_objects(copy=False)
n.links["length_original"] = n.links["length_original"].fillna(n.links.length)


def set_length_based_efficiency(n, carrier, bus_suffix, transmission_efficiency):
"""
Set the efficiency of all links of type carrier in network n based on their length and the values specified in the config.
Additionally add the length based electricity demand required for compression (if applicable).
The bus_suffix refers to the suffix that differentiates the links bus0 from the corresponding electricity bus, i.e. " H2".
Important:
Call this function AFTER lossy_bidirectional_links when creating links that are both bidirectional and lossy,
and have a length based electricity demand for compression. Otherwise the compression will not consistently take place at
the inflow bus and instead vary between the inflow and the outflow bus.
"""

# get the links length based efficiency and required compression
if carrier not in transmission_efficiency:
raise KeyError(
f"An error occurred when setting the length based efficiency for the Links of type {carrier}."
f"The Link type {carrier} was not found in the config under config['sector']['transmission_efficiency']."
)
efficiencies = transmission_efficiency[carrier]
efficiency_static = efficiencies.get("efficiency_static", 1)
efficiency_per_1000km = efficiencies.get("efficiency_per_1000km", 1)
compression_per_1000km = efficiencies.get("compression_per_1000km", 0)

# indetify all links of type carrier
carrier_i = n.links.loc[n.links.carrier == carrier].index

# identify the lengths of all links of type carrier
# use "length_original" for lossy bidirectional links and "length" for any other link
if ("reversed" in n.links.columns) and any(n.links.loc[carrier_i, "reversed"]):
lengths = n.links.loc[carrier_i, "length_original"]
else:
lengths = n.links.loc[carrier_i, "length"]

# set the links' length based efficiency
n.links.loc[carrier_i, "efficiency"] = (
efficiency_static * efficiency_per_1000km ** (lengths / 1e3)
)

# set the links's electricity demand for compression
if compression_per_1000km > 0:
# connect the links to their corresponding electricity buses
n.links.loc[carrier_i, "bus2"] = n.links.loc[
carrier_i, "bus0"
].str.removesuffix(bus_suffix)
# TODO: use these lines to set bus 2 instead, once n.buses.location is functional and remove bus_suffix.
"""
n.links.loc[carrier_i, "bus2"] = n.links.loc[carrier_i, "bus0"].map(
n.buses.location
) # electricity
"""
# set the required compression demand
n.links.loc[carrier_i, "efficiency2"] = -compression_per_1000km * lengths / 1e3
23 changes: 18 additions & 5 deletions scripts/add_extra_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,13 @@
import numpy as np
import pandas as pd
import pypsa
from _helpers import configure_logging, create_logger
from _helpers import (
configure_logging,
create_logger,
lossy_bidirectional_links,
override_component_attrs,
set_length_based_efficiency,
)
from add_electricity import (
_add_missing_carriers_from_costs,
add_nice_carrier_names,
Expand Down Expand Up @@ -225,7 +231,7 @@ def attach_stores(n, costs, config):
)


def attach_hydrogen_pipelines(n, costs, config):
def attach_hydrogen_pipelines(n, costs, config, transmission_efficiency):
elec_opts = config["electricity"]
ext_carriers = elec_opts["extendable_carriers"]
as_stores = ext_carriers.get("Store", [])
Expand Down Expand Up @@ -261,10 +267,15 @@ def attach_hydrogen_pipelines(n, costs, config):
p_nom_extendable=True,
length=h2_links.length.values,
capital_cost=costs.at["H2 pipeline", "capital_cost"] * h2_links.length,
efficiency=costs.at["H2 pipeline", "efficiency"],
carrier="H2 pipeline",
Eddy-JV marked this conversation as resolved.
Show resolved Hide resolved
)

# split the pipeline into two unidirectional links to properly apply transmission losses in both directions.
lossy_bidirectional_links(n, "H2 pipeline")
Eddy-JV marked this conversation as resolved.
Show resolved Hide resolved

# set the pipelines efficiency and the electricity required by the pipeline for compression
set_length_based_efficiency(n, "H2 pipeline", " H2", transmission_efficiency)


if __name__ == "__main__":
if "snakemake" not in globals():
Eddy-JV marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -274,8 +285,10 @@ def attach_hydrogen_pipelines(n, costs, config):

configure_logging(snakemake)

n = pypsa.Network(snakemake.input.network)
overrides = override_component_attrs(snakemake.input.overrides)
n = pypsa.Network(snakemake.input.network, override_component_attrs=overrides)
Nyears = n.snapshot_weightings.objective.sum() / 8760.0
transmission_efficiency = snakemake.params.transmission_efficiency
config = snakemake.config

costs = load_costs(
Expand All @@ -287,7 +300,7 @@ def attach_hydrogen_pipelines(n, costs, config):

attach_storageunits(n, costs, config)
attach_stores(n, costs, config)
attach_hydrogen_pipelines(n, costs, config)
attach_hydrogen_pipelines(n, costs, config, transmission_efficiency)

add_nice_carrier_names(n, config=snakemake.config)

Expand Down
64 changes: 59 additions & 5 deletions scripts/solve_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,62 @@ def add_existing(n):
n.generators.loc[tech_index, tech] = existing_res


def add_lossy_bidirectional_link_constraints(n: pypsa.components.Network) -> None:
Eddy-JV marked this conversation as resolved.
Show resolved Hide resolved
"""
ekatef marked this conversation as resolved.
Show resolved Hide resolved
Ensures that the two links simulating a bidirectional_link are extended the same amount.
"""

if not n.links.p_nom_extendable.any() or "reversed" not in n.links.columns:
return

# ensure that the 'reversed' column is boolean and identify all link carriers that have 'reversed' links
n.links["reversed"] = n.links.reversed.fillna(0).astype(bool)
carriers = n.links.loc[n.links.reversed, "carrier"].unique() # noqa: F841
ekatef marked this conversation as resolved.
Show resolved Hide resolved

# get the indices of all forward links (non-reversed), that have a reversed counterpart
forward_i = n.links.loc[
n.links.carrier.isin(carriers) & ~n.links.reversed & n.links.p_nom_extendable
].index

# function to get backward (reversed) indices corresponding to forward links
# this function is required to properly interact with the myopic naming scheme
def get_backward_i(forward_i):
return pd.Index(
[
(
re.sub(r"-(\d{4})$", r"-reversed-\1", s)
if re.search(r"-\d{4}$", s)
else s + "-reversed"
)
for s in forward_i
]
)
Eddy-JV marked this conversation as resolved.
Show resolved Hide resolved

# get the indices of all backward links (reversed)
backward_i = get_backward_i(forward_i)

# get the p_nom optimization variables for the links using the get_var function
links_p_nom = get_var(n, "Link", "p_nom")

# only consider forward and backward links that are present in the optimization variables
subset_forward = forward_i.intersection(links_p_nom.index)
subset_backward = backward_i.intersection(links_p_nom.index)

# ensure we have a matching number of forward and backward links
if len(subset_forward) != len(subset_backward):
raise ValueError("Mismatch between forward and backward links.")

# define the lefthand side of the constrain p_nom (forward) - p_nom (backward) = 0
# this ensures that the forward links always have the same maximum nominal power as their backward counterpart
lhs = linexpr(
(1, get_var(n, "Link", "p_nom")[backward_i].to_numpy()),
(-1, get_var(n, "Link", "p_nom")[forward_i].to_numpy()),
)

# add the constraint to the PySPA model
define_constraints(n, lhs, "=", 0, "Link-bidirectional_sync")


def extra_functionality(n, snapshots):
"""
Collects supplementary constraints which will be passed to
Expand Down Expand Up @@ -881,6 +937,7 @@ def extra_functionality(n, snapshots):
if "EQ" in o:
add_EQ_constraints(n, o)
add_battery_constraints(n)
add_lossy_bidirectional_link_constraints(n)

if (
snakemake.config["policy_config"]["hydrogen"]["temporal_matching"]
Expand Down Expand Up @@ -986,11 +1043,8 @@ def solve_network(n, config, solving={}, opts="", **kwargs):

is_sector_coupled = "sopts" in snakemake.wildcards.keys()

if is_sector_coupled:
overrides = override_component_attrs(snakemake.input.overrides)
n = pypsa.Network(snakemake.input.network, override_component_attrs=overrides)
else:
n = pypsa.Network(snakemake.input.network)
overrides = override_component_attrs(snakemake.input.overrides)
n = pypsa.Network(snakemake.input.network, override_component_attrs=overrides)

if snakemake.params.augmented_line_connection.get("add_to_snakefile"):
n.lines.loc[n.lines.index.str.contains("new"), "s_nom_min"] = (
Expand Down
4 changes: 2 additions & 2 deletions test/config.sector.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ countries: ["NG", "BJ"]

electricity:
extendable_carriers:
Store: []
Link: []
Store: [H2]
Link: [H2 pipeline]

co2limit: 7.75e7

Expand Down
Loading