Wind farm optimisation - 2 GW of dedicated offshore wind for hydrogen production#

import os

import cartopy.crs as ccrs
import contextily as cx
import geopandas as gpd
import mapclassify as mc
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
from cartopy.mpl.ticker import LatitudeFormatter, LongitudeFormatter
from matplotlib_scalebar.scalebar import ScaleBar

from h2ss import capacity as cap
from h2ss import compare
from h2ss import data as rd
from h2ss import functions as fns
from h2ss import optimisation as opt
# basemap cache directory
cx.set_cache_dir(os.path.join("data", "basemaps"))

Halite data#

ds, extent = rd.kish_basin_data_depth_adjusted(
    dat_path=os.path.join("data", "kish-basin"),
    bathymetry_path=os.path.join("data", "bathymetry"),
)

Constraints#

# exploration wells
_, wells_b = fns.constraint_exploration_well(
    data_path=os.path.join(
        "data",
        "exploration-wells",
        "Exploration_Wells_Irish_Offshore.shapezip.zip",
    )
)

# wind farms
wind_farms = fns.constraint_wind_farm(
    data_path=os.path.join(
        "data", "wind-farms", "marine-area-consent-wind.zip"
    )
)

# frequent shipping routes
_, shipping_b = fns.constraint_shipping_routes(
    data_path=os.path.join(
        "data", "shipping", "shipping_frequently_used_routes.zip"
    ),
    dat_extent=extent,
)

# shipwrecks
_, shipwrecks_b = fns.constraint_shipwrecks(
    data_path=os.path.join(
        "data", "shipwrecks", "IE_GSI_MI_Shipwrecks_IE_Waters_WGS84_LAT.zip"
    ),
    dat_extent=extent,
)

# subsea cables
_, cables_b = fns.constraint_subsea_cables(
    data_path=os.path.join("data", "subsea-cables", "KIS-ORCA.gpkg"),
    dat_extent=extent,
)
# distance from salt formation edge
edge_buffer = fns.constraint_halite_edge(dat_xr=ds)

Zones of interest#

zones, zds = fns.zones_of_interest(
    dat_xr=ds,
    constraints={"net_height": 120, "min_depth": 500, "max_depth": 2000},
)

Generate caverns#

caverns = fns.generate_caverns_hexagonal_grid(
    zones_df=zones,
    dat_extent=extent,
)
caverns = fns.cavern_dataframe(
    dat_zone=zds,
    cavern_df=caverns,
    depths={"min": 500, "min_opt": 1000, "max_opt": 1500, "max": 2000},
)
# label caverns by depth and heights
caverns = fns.label_caverns(
    cavern_df=caverns,
    heights=[120],
    depths={"min": 500, "min_opt": 1000, "max_opt": 1500, "max": 2000},
)
caverns, _ = fns.generate_caverns_with_constraints(
    cavern_df=caverns,
    exclusions={
        "wells": wells_b,
        "wind_farms": wind_farms,
        "shipwrecks": shipwrecks_b,
        "shipping": shipping_b,
        "cables": cables_b,
        "edge": edge_buffer,
    },
)
Without constraints...
Number of potential caverns: 568
------------------------------------------------------------
Excluding salt formation edges...
Number of potential caverns: 539
------------------------------------------------------------
Exclude shipping...
Number of potential caverns: 261
Caverns excluded: 51.58%
------------------------------------------------------------
Exclude wind farms...
Number of potential caverns: 218
Caverns excluded: 59.55%
------------------------------------------------------------
Exclude cables...
Number of potential caverns: 218
Caverns excluded: 59.55%
------------------------------------------------------------
Exclude wells...
Number of potential caverns: 218
Caverns excluded: 59.55%
------------------------------------------------------------
Exclude shipwrecks...
Number of potential caverns: 218
Caverns excluded: 59.55%
------------------------------------------------------------

Capacity#

caverns["cavern_total_volume"] = cap.cavern_volume(
    height=caverns["cavern_height"]
)
caverns["cavern_volume"] = cap.corrected_cavern_volume(
    v_cavern=caverns["cavern_total_volume"]
)

caverns["t_mid_point"] = cap.temperature_cavern_mid_point(
    height=caverns["cavern_height"], depth_top=caverns["cavern_depth"]
)

(
    caverns["p_operating_min"],
    caverns["p_operating_max"],
) = cap.pressure_operating(
    thickness_overburden=caverns["TopDepthSeabed"],
    depth_water=-caverns["Bathymetry"],
)

caverns["rho_min"], caverns["rho_max"] = cap.density_hydrogen_gas(
    p_operating_min=caverns["p_operating_min"],
    p_operating_max=caverns["p_operating_max"],
    t_mid_point=caverns["t_mid_point"],
)

(
    caverns["working_mass"],
    caverns["mass_operating_min"],
    caverns["mass_operating_max"],
) = cap.mass_hydrogen_working(
    rho_h2_min=caverns["rho_min"],
    rho_h2_max=caverns["rho_max"],
    v_cavern=caverns["cavern_volume"],
)

caverns["capacity"] = cap.energy_storage_capacity(
    m_working=caverns["working_mass"]
)

Power curve [MW] and Weibull wind speed distribution#

# extract data for wind farms at 150 m
weibull_wf_df = fns.read_weibull_data(
    data_path_weibull=os.path.join(
        "data", "weibull-parameters-wind-speeds", "Weibull_150m_params_ITM.zip"
    ),
    data_path_wind_farms=os.path.join(
        "data", "wind-farms", "marine-area-consent-wind.zip"
    ),
)
weibull_powercurve = opt.weibull_distribution(weibull_wf_data=weibull_wf_df)

Number of reference wind turbines#

# 2 GW of offshore wind for green hydrogen production by 2030
# in addition to 5 GW offshore wind target in CLimate Action Plan 2023
# pg. 134
# assume this 2 GW is distributed evenly to the total capacity
print(
    f"{2 / 7 * 100:.2f}% of total offshore wind farm capacity dedicated "
    f"to H\N{SUBSCRIPT TWO} production"
)
28.57% of total offshore wind farm capacity dedicated to H₂ production
# max wind farm capacity
weibull_wf_df["cap"] = [int(x * 2 / 7) for x in [1300, 824, 500]]
# number of 15 MW turbines, rounded down to the nearest integer
weibull_wf_df["n_turbines"] = opt.number_of_turbines(
    owf_cap=weibull_wf_df["cap"]
)

Annual energy production [MWh]#

weibull_wf_df = opt.annual_energy_production(weibull_wf_data=weibull_wf_df)

Annual hydrogen production [kg]#

weibull_wf_df["AHP"] = opt.annual_hydrogen_production(aep=weibull_wf_df["AEP"])

AHP as a proportion of the total working mass#

weibull_wf_df["AHP_frac"] = (
    weibull_wf_df["AHP"] / caverns[["working_mass"]].sum().iloc[0]
)

AHP converted to storage demand [GWh]#

weibull_wf_df["demand"] = cap.energy_storage_capacity(
    m_working=weibull_wf_df["AHP"]
)

Number of caverns required based on cumulative working mass and AHP#

compare.calculate_number_of_caverns(
    cavern_df=caverns, weibull_wf_data=weibull_wf_df
)
Codling Wind Park
Working mass [kg]: 3.057689E+07
Number of caverns required: 7–17
Capacity (approx.) [GWh]: 1,087.05
------------------------------------------------------------------------------
Dublin Array
Working mass [kg]: 1.872921E+07
Number of caverns required: 4–11
Capacity (approx.) [GWh]: 656.97
------------------------------------------------------------------------------
North Irish Sea Array
Working mass [kg]: 1.231565E+07
Number of caverns required: 3–7
Capacity (approx.) [GWh]: 471.43
------------------------------------------------------------------------------
Total number of caverns required: 14–35
------------------------------------------------------------------------------
Number of caverns required as a percentage of all available caverns:
6.42–16.06%
------------------------------------------------------------------------------
Total maximum cavern capacity (approx.): 2,215.45 GWh

Transmission distance [km]#

caverns, injection_point = opt.transmission_distance(
    cavern_df=caverns, wf_data=wind_farms
)

Electrolyser capacity [MW]#

weibull_wf_df["E_cap"] = opt.electrolyser_capacity(
    n_turbines=weibull_wf_df["n_turbines"]
)

CAPEX for pipeline [€ km⁻¹]#

weibull_wf_df["CAPEX"] = opt.capex_pipeline(e_cap=weibull_wf_df["E_cap"])
weibull_wf_df
name cap (c, min) (c, max) (c, mean) (k, min) (k, max) (k, mean) n_turbines AEP integral abserr AHP AHP_frac demand E_cap CAPEX
0 Codling Wind Park 371 10.2 10.8 10.500000 1.9 2.0 1.950000 24 1.548908e+06 8.335975 2.528685e-07 3.057689e+07 0.043023 1018.889987 298 953350.780836
1 Dublin Array 235 9.9 10.6 10.292857 1.9 2.0 1.950000 15 9.487500e+05 8.169630 3.778893e-07 1.872921e+07 0.026353 624.098782 186 868435.651965
2 North Irish Sea Array 142 10.7 11.2 10.950000 2.1 2.2 2.133333 9 6.238637e+05 8.953423 1.516351e-07 1.231565e+07 0.017329 410.384794 112 806313.935209
# totals
weibull_wf_df[
    ["cap", "n_turbines", "AEP", "AHP", "AHP_frac", "demand", "E_cap", "CAPEX"]
].sum()
cap           7.480000e+02
n_turbines    4.800000e+01
AEP           3.121522e+06
AHP           6.162175e+07
AHP_frac      8.670528e-02
demand        2.053374e+03
E_cap         5.960000e+02
CAPEX         2.628100e+06
dtype: float64
compare.electricity_demand_ie(data=weibull_wf_df["demand"])
Energy capacity as a percentage of Ireland's electricity demand
in 2050 (84–122 TWh electricity), assuming a conversion efficiency
of 50%: 0.84–1.22%
Energy capacity as a percentage of Ireland's hydrogen demand
in 2050, assuming it is 17% of the electricity demand
(84–122 TWh electricity): 9.90–14.38%
compare.hydrogen_demand_ie(data=weibull_wf_df["demand"])
Energy capacity as a percentage of Ireland's domestic hydrogen
demand in 2050 (4.6–39 TWh hydrogen): 5.27–44.64%
Energy capacity as a percentage of Ireland's domestic and
non-domestic hydrogen demand in 2050 (19.8–74.6 TWh hydrogen): 2.75–10.37%

LCOT for pipeline [€ kg⁻¹]#

caverns = opt.lcot_pipeline(weibull_wf_data=weibull_wf_df, cavern_df=caverns)
caverns[
    [
        "cavern_depth",
        "working_mass",
        "capacity",
        "distance_ip",
    ]
    + list(caverns.filter(like="dist_"))
    + list(caverns.filter(like="LCOT_"))
].describe()
cavern_depth working_mass capacity distance_ip dist_Codling_Wind_Park dist_Dublin_Array dist_North_Irish_Sea_Array LCOT_Codling_Wind_Park LCOT_Dublin_Array LCOT_North_Irish_Sea_Array
count 218.000000 2.180000e+02 218.000000 218.000000 218.000000 218.000000 218.000000 218.000000 218.000000 218.000000
mean 1154.020877 3.260108e+06 108.634041 29.197826 58.408245 43.581633 44.393167 0.184989 0.205274 0.295240
std 365.142804 7.786651e+05 25.946851 6.830597 11.452952 13.055932 7.523248 0.036273 0.061495 0.050034
min 502.745339 1.678950e+06 55.946359 16.263066 33.027322 17.542931 32.980512 0.104603 0.082629 0.219339
25% 872.651330 2.685380e+06 89.482842 23.216441 51.592318 33.503367 36.808911 0.163402 0.157804 0.244800
50% 1128.329146 3.277441e+06 109.211632 30.947028 58.988376 47.378240 44.619590 0.186826 0.223156 0.296746
75% 1433.385847 3.886101e+06 129.493525 34.863247 67.109072 54.107289 50.773606 0.212546 0.254851 0.337673
max 1980.680682 4.765555e+06 158.798899 42.990158 80.806729 70.176200 67.926218 0.255928 0.330537 0.451748
pd.Series(caverns[list(caverns.filter(like="dist_"))].values.flat).describe()
count    654.000000
mean      48.794348
std       12.862247
min       17.542931
25%       36.924385
50%       49.890968
75%       57.488679
max       80.806729
dtype: float64
pd.Series(caverns[list(caverns.filter(like="LCOT_"))].values.flat).describe()
count    654.000000
mean       0.228501
std        0.069462
min        0.082629
25%        0.172666
50%        0.228186
75%        0.279192
max        0.451748
dtype: float64
fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))
for n, col, lab, show_legend in zip(
    [0, 1],
    ["dist_", "LCOT_"],
    ["Transmission distance [km]", "Pipeline LCOT [€ kg⁻¹]"],
    [False, True],
):
    sns.boxplot(
        caverns.filter(like=col)
        .set_axis(list(wind_farms["name"]), axis=1)
        .melt(),
        y="value",
        hue="variable",
        palette=sns.color_palette(["tab:red", "tab:gray", "tab:blue"]),
        width=0.35,
        ax=axes[n],
        legend=show_legend,
        linecolor="black",
        linewidth=1.1,
        gap=0.15,
        showmeans=True,
        meanprops={
            "marker": "d",
            "markeredgecolor": "black",
            "markerfacecolor": "none",
        },
    )
    axes[n].set_ylabel(lab)
    axes[n].tick_params(axis="x", bottom=False)
    axes[n].yaxis.grid(True, linewidth=0.25)
axes[1].legend(loc="lower right")
sns.despine(bottom=True)
plt.tight_layout()
plt.show()
../_images/6bf3ff71dc9219cbe9354ecc5959ec548cf696c3c7ae895aaeed62887a9fac50.png

Maps#

shape = rd.halite_shape(dat_xr=ds).buffer(1000).buffer(-1000)
def plot_map_facet(
    cavern_df,
    classes,
    fontsize=11.5,
):
    """Helper function for plotting LCOT facet maps"""
    fig1 = plt.figure(figsize=(11, 11.75))
    xmin_, ymin_, xmax_, ymax_ = cavern_df.total_bounds
    colours = [int(n * 255 / (len(classes) - 1)) for n in range(len(classes))]
    legend_handles = []
    classes = sorted(classes)

    for n1, c in enumerate(colours):
        if n1 == 0:
            label = f"< {classes[n1]:.2f}"
        elif n1 == len(colours) - 1:
            label = f"≥ {classes[-2]:.2f}"
        else:
            label = f"{classes[n1 - 1]:.2f} – < {classes[n1]:.2f}"
        legend_handles.append(
            mpatches.Patch(
                facecolor=sns.color_palette("flare", 256)[c], label=label
            )
        )

    for a, wf1 in enumerate(list(wind_farms["name"])):
        ax1 = fig1.add_subplot(2, 2, a + 1, projection=ccrs.epsg(rd.CRS))
        gpd.GeoDataFrame(cavern_df, geometry=cavern_df.centroid).plot(
            ax=ax1,
            scheme="UserDefined",
            classification_kwds={"bins": classes},
            column=f"LCOT_{wf1.replace(' ', '_')}",
            zorder=2,
            marker=".",
            cmap="flare",
            markersize=20,
        )
        shape.plot(
            ax=ax1, color="white", alpha=0.5, edgecolor="slategrey", zorder=1
        )
        cx.add_basemap(
            ax1,
            crs=rd.CRS,
            source=cx.providers.CartoDB.VoyagerNoLabels,
            attribution=False,
        )
        ax1.gridlines(
            draw_labels={"bottom": "x"},
            color="lightslategrey",
            alpha=0.25,
            xlabel_style={"fontsize": fontsize},
            xformatter=LongitudeFormatter(auto_hide=False, dms=True),
        )
        if not a == 1:
            ax1.gridlines(
                draw_labels={"left": "y"},
                color="lightslategrey",
                alpha=0.25,
                ylabel_style={"fontsize": fontsize, "rotation": 89.9},
                yformatter=LatitudeFormatter(auto_hide=False, dms=True),
            )
        if a == 2:
            ax1.add_artist(
                ScaleBar(
                    1,
                    box_alpha=0,
                    location="lower right",
                    color="darkslategrey",
                    font_properties={"size": fontsize},
                )
            )
            plt.legend(
                loc="lower right",
                bbox_to_anchor=(1, 0.075),
                handles=legend_handles,
                title="Pipeline LCOT [€ kg⁻¹]",
                fontsize=fontsize,
                title_fontsize=fontsize,
            )
        plt.xlim(xmin_ - 1000, xmax_ + 1000)
        plt.ylim(ymin_ - 1000, ymax_ + 1000)
        ax1.set_title(list(wind_farms["name"])[a])

    plt.tight_layout()
    # plt.savefig(
    #     os.path.join("graphics", "fig_map_transmission_2gw.jpg"),
    #     format="jpg", dpi=600
    # )
    plt.show()
plot_map_facet(
    caverns,
    list(mc.Quantiles(caverns[list(caverns.filter(like="LCOT_"))], k=6).bins),
)
../_images/8364a38025445065129f7ed1eae96d8d013f952be4b07b8f931f9de7b4c7d3df.png