Fos-sur-Mer: what is hydrogen storage worth, and what does doing without the gas reformer cost?¶

POMMES case study · PERSEE, MINES Paris – PSL · a textbook case, not a forecast.

The industrial zone of Fos-sur-Mer uses 83.5 kt of hydrogen a year (refining, methanol), today made by a steam-methane reformer (SMR) burning natural gas, plus some by-product hydrogen from a chlorine plant (KEM ONE, 43 MW). An electrolyser could make it instead, buying electricity at the hourly price. Two questions:

  1. What is a hydrogen storage worth — the Manosque salt cavern, 110 km away — to that electrolyser, and how does that value depend on the electrolyser's investment cost (CAPEX)?
  2. What does it cost to do without the gas reformer as a back-up, i.e. to impose 100 % electrolytic hydrogen?

How. Hourly French electricity prices come from a European POMMES model of the 2030 power system (16 zones, 11 weather years, three gas prices; T. Knibiehly, PhD, PERSEE). The hub itself is a one-node linear programme — the reduced model — that chooses electrolyser and cavern sizes and their hourly operation at least annual cost. It reproduces the full POMMES modelling of the Fos hub at every point POMMES solved (section 3). The equations and costs live in regles_parametriques/regles/cavite.py; fos_case.py only reads the frozen data and calls them.

Assumptions, units and sources: hypotheses.yaml. Interactive version: page.html.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import fos_case as fc, analyse
pd.set_option("display.precision", 2)
print("demand:", round(fc.kt_par_an(fc.DEMANDE_FOS_MW), 1), "kt/yr =", round(fc.DEMANDE_FOS_MW, 1), "MW flat")
demand: 83.5 kt/yr = 317.7 MW flat

1. The electricity prices¶

What matters for storage is not the average price but the spread between cheap and expensive hours. The gas price sets that spread: gas-fired plants set the price in expensive hours, renewables and nuclear in cheap ones.

In [2]:
fig, ax = plt.subplots(figsize=(8, 4))
for g, c in zip(fc.GAZ, ["#5DA9DE", "#005E9E", "#0B2E4F"]):
    p = np.sort(fc.prix("cy2009", g))[::-1]
    ax.plot(np.arange(len(p)) / len(p) * 100, p, color=c, label=f"gas €{g}/MWh — mean {p.mean():.0f} €/MWh")
ax.set(xlabel="share of the year (%)", ylabel="€/MWh", ylim=(0, 250), title="Price duration curve, France, 2030, weather year 2009")
ax.legend(frameon=False); ax.grid(ls=":")
No description has been provided for this image
In [3]:
tab = pd.DataFrame({(a, g): [fc.prix(a, g).mean()] for a in fc.ANNEES for g in fc.GAZ}).T[0].unstack()
tab.columns = [f"gas {g}" for g in tab.columns]
tab.round(1)   # mean French price (€/MWh) by weather year
Out[3]:
gas 15 gas 30 gas 65
cy1983 37.5 50.4 78.2
cy1985 43.6 58.1 90.1
cy1987 45.3 61.9 97.2
cy1990 36.2 48.5 75.2
cy1996 44.4 59.2 92.4
cy2000 37.1 48.1 73.3
cy2003 44.6 61.5 99.3
cy2009 43.7 58.6 85.4
cy2010 48.0 65.7 107.3
cy2012 42.5 57.0 88.2
cy2014 38.5 51.0 77.7

2. One run of the reduced model¶

100 % electrolytic hydrogen, electrolyser at 1,350 €/kW_e (RTE reference), gas at €30/MWh, weather year 2009. Without the cavern, the electrolyser follows the flat demand and pays the average price. With it, the electrolyser is oversized and runs only in the cheapest hours; the cavern fills in spring and summer and empties in winter.

In [4]:
p = fc.prix("cy2009", 30)
par = fc.parametres(1350, 0.68)
res, sol = fc.cavite.valeur_lp(p, par, avec_solution=True)
print(f"solved in {sol.temps_s:.1f} s — value of the cavern: {res.valeur_eur_par_kg:.3f} €/kg "
      f"({100 * res.part_cout:.1f} % of the cost of electrolytic H2 without it), cavern {sol.E_mwh_h2 / 1e3:.0f} GWh, "
      f"electrolyser {sol.P_mw_h2 / 0.68:.0f} MW_e, running {100 * res.x:.0f} % of the hours")
fig, ax = plt.subplots(2, 1, figsize=(9, 5), sharex=True)
j = np.arange(8760) / 24
ax[0].plot(j, p, lw=0.3, color="#9D9D9D"); ax[0].set(ylabel="€/MWh", ylim=(0, 200))
ax[1].plot(j, sol.s_mwh_h2 / 1e3, color="#005E9E"); ax[1].set(ylabel="cavern level (GWh)", xlabel="day of the year")
for a in ax: a.grid(ls=":")
solved in 10.2 s — value of the cavern: 0.442 €/kg (11.9 % of the cost of electrolytic H2 without it), cavern 200 GWh, electrolyser 1109 MW_e, running 45 % of the hours
No description has been provided for this image

3. Check: the reduced model against the full POMMES modelling¶

POMMES solved the Fos hub (without the steel plant) for three CAPEX levels, 11 weather years, with and without the cavern, at several mandate levels. The reduced model is solved here on a few of those points and compared on the difference of annual cost — the quantity both questions are about.

In [5]:
R = fc.pommes()
rows = []
for annee in ("cy1990", "cy2009", "cy2014"):
    for phi in (0.6, 1.0):
        q = R[(R.niveau_capex == "rte_ref") & (R.gaz_eur_mwh == 30) & (R.demande_europe == 1000) & (R.taxe_eur_t == 100)
              & (R.annee == annee)]
        tot = {s: q[(q.stockage == s) & (q.obligation == phi)].annualised_totex_eur.iloc[0] / 1e6 for s in ("no_storage", "manosque")}
        p = fc.prix(annee, 30)
        par = fc.parametres(1350, 0.68, part_min=phi, gaz=30)
        lp = {cav: fc.systeme(p, par, avec_cavite=cav).cout_meur_an for cav in (False, True)}
        rows.append({"year": annee, "mandate": phi, "value of cavern, POMMES (€m/yr)": tot["no_storage"] - tot["manosque"],
                     "value of cavern, reduced (€m/yr)": lp[False] - lp[True]})
pd.DataFrame(rows).round(3)
Out[5]:
year mandate value of cavern, POMMES (€m/yr) value of cavern, reduced (€m/yr)
0 cy1990 0.6 21.19 21.19
1 cy1990 1.0 50.31 50.31
2 cy2009 0.6 12.02 12.02
3 cy2009 1.0 36.90 36.90
4 cy2014 0.6 18.61 18.61
5 cy2014 1.0 53.77 53.77

4. Question 1 — the value of storage, and the electrolyser's CAPEX¶

Grid of the reduced model (grille.py stockage): 7 CAPEX × 3 gas prices × 5 hydrogen volumes × 3 cavern caps × 11 weather years. Dots: POMMES (mean of the 11 years).

In [6]:
G = analyse.grille("stockage")
V = analyse.valeur_pommes()
fos = round(fc.kt_par_an(fc.DEMANDE_FOS_MW), 2)
g = G[(G.demande_kt == fos) & (G.volume_max_gwh == 200)]
fig, ax = plt.subplots(figsize=(8, 4.5))
for gaz, c in zip(fc.GAZ, ["#5DA9DE", "#005E9E", "#0B2E4F"]):
    s = g[g.gaz == gaz].groupby("capex").valeur_eur_par_kg.agg(["mean", "min", "max"])
    ax.plot(s.index, s["mean"], color=c, label=f"gas €{gaz}/MWh")
    ax.fill_between(s.index, s["min"], s["max"], color=c, alpha=0.1)
    pv = V[(V.obligation == 1) & (V.gaz_eur_mwh == gaz) & (V.demande_europe == 1000)].groupby("capex_eur_kw").valeur_eur_kg.mean()
    ax.scatter(pv.index, pv.values, facecolor="white", edgecolor=c, zorder=5)
ax.set(xlabel="electrolyser CAPEX (€/kW_e)", ylabel="value of the cavern (€/kg H2)", ylim=(0, None))
ax.legend(frameon=False); ax.grid(ls=":")
No description has been provided for this image
In [7]:
# The numbers quoted in the post (POMMES, mean and range over 11 weather years)
t = V[(V.demande_europe == 1000) & (V.obligation.isin([0, 1]))]
t.groupby(["obligation", "gaz_eur_mwh", "capex_eur_kw"]).agg(
    value_eur_kg=("valeur_eur_kg", "mean"), worst_year=("valeur_eur_kg", "min"), best_year=("valeur_eur_kg", "max"),
    share_of_cost=("part_cout", "mean"), value_meur_yr=("valeur_meur_an", "mean")).round(2)
Out[7]:
value_eur_kg worst_year best_year share_of_cost value_meur_yr
obligation gaz_eur_mwh capex_eur_kw
0.0 30 417.0 0.25 0.10 0.39 0.19 20.94
1000.0 0.03 -0.00 0.08 0.02 2.15
1350.0 0.00 -0.00 0.00 0.00 0.00
1.0 15 1000.0 0.19 0.04 0.35 0.08 16.27
1350.0 0.06 -0.00 0.17 0.02 5.35
20 1000.0 0.35 0.16 0.52 0.13 29.09
1350.0 0.17 0.00 0.34 0.06 13.78
25 1000.0 0.52 0.29 0.71 0.17 43.50
1350.0 0.32 0.09 0.52 0.10 26.38
30 417.0 1.34 1.21 1.45 0.42 112.29
1000.0 0.70 0.47 0.89 0.22 58.44
1350.0 0.48 0.22 0.70 0.14 40.41
35 1000.0 0.87 0.67 1.07 0.25 73.05
1350.0 0.65 0.36 0.89 0.17 54.43
40 1000.0 1.06 0.88 1.25 0.29 88.34
1350.0 0.83 0.56 1.07 0.21 69.50
45 1000.0 1.24 1.08 1.42 0.32 103.28
1350.0 1.01 0.78 1.25 0.24 84.47
50 1000.0 1.41 1.24 1.59 0.34 117.61
1350.0 1.18 1.00 1.43 0.27 98.90
55 1000.0 1.59 1.39 1.77 0.37 132.38
1350.0 1.36 1.16 1.61 0.29 113.78
60 1000.0 1.76 1.53 1.97 0.39 147.21
1350.0 1.54 1.31 1.78 0.32 128.56
65 1000.0 1.93 1.68 2.20 0.41 161.37
1350.0 1.71 1.46 1.96 0.34 142.82

Reading. Under a 100 % mandate the cavern always has value, and that value falls as the electrolyser gets dearer: a cheap electrolyser is oversized more, so it can shift more of its purchases to cheap hours. The gas price matters as much as the CAPEX, although no gas is burnt at 100 %: it acts only through the spread of electricity prices. Without a mandate, at the central CAPEX, the cavern is worth nothing: electrolysis is built in only 3 weather years out of 11, to make about 40 % of the hydrogen in cheap hours, and the existing gas reformer provides the flexibility.

The volume of hydrogen¶

For the same cavern, a smaller demand can be shifted over longer periods (the cavern's autonomy grows), so the value per kg rises; a larger one (Fos with a hydrogen-based steel plant, 287.5 kt/yr) fills the cavern in two weeks. The model is homogeneous: only the ratio cap / demand matters (25 kt/yr with 100 GWh = 50 kt/yr with 200 GWh). Below: gas €30/MWh, 1,350 €/kW_e.

In [8]:
s = G[(G.gaz == 30) & (G.capex == 1350)].groupby(["demande_kt", "volume_max_gwh"]).valeur_eur_par_kg.mean().unstack()
s.columns = [f"cap {c:g} GWh" for c in s.columns]
s.round(2)   # €/kg by hydrogen demand (kt/yr)
Out[8]:
cap 100 GWh cap 200 GWh cap 400 GWh
demande_kt
25.00 0.52 0.53 0.53
50.00 0.47 0.52 0.53
83.49 0.42 0.48 0.52
150.00 0.37 0.43 0.49
287.47 0.30 0.37 0.44

A one-line rule¶

The simplified model of regles/cavite.py replaces the LP by a sort of hourly prices in windows as long as the cavern's autonomy. It needs no solver (~1 ms) — how far is it from the LP?

In [9]:
e = (G.simplifie_eur_par_kg - G.valeur_eur_par_kg)
print(f"simplified − LP over {len(G)} cases: median {e.median():+.3f} €/kg, "
      f"median |error| {e.abs().median():.3f} €/kg, 90th pct |error| {e.abs().quantile(.9):.3f} €/kg")
G.assign(err=e).groupby(["demande_kt"]).err.describe()[["mean", "50%", "min", "max"]].round(3)
simplified − LP over 3465 cases: median -0.010 €/kg, median |error| 0.017 €/kg, 90th pct |error| 0.067 €/kg
Out[9]:
mean 50% min max
demande_kt
25.00 -1.60e-02 -1.30e-02 -0.18 0.16
50.00 -1.40e-02 -1.10e-02 -0.18 0.16
83.49 -1.40e-02 -1.20e-02 -0.16 0.17
150.00 -9.00e-03 -7.00e-03 -0.18 0.16
287.47 -7.00e-03 -5.00e-03 -0.15 0.14

5. Question 2 — the cost of doing without the reformer¶

A mandate fixes a minimum electrolytic share of the hydrogen served. The cost of the mandate is measured against the unconstrained hub (the least-cost hub with no mandate, carbon tax of €100/t included), and read excluding the carbon tax — a transfer, not a cost to society.

⚠️ The chlorine by-product (43 MW, 13.5 % of the hydrogen) emits nothing in the model but counts as non-electrolytic. At an 86.5 % mandate the reformer no longer runs; beyond, electrolysis replaces chlorine and avoids no CO₂.

In [10]:
O = analyse.obligation_pommes()
Gh = analyse.obligation_reduit()
fig, ax = plt.subplots(figsize=(8, 4.5))
for cav, c in ((False, "#C77F00"), (True, "#005E9E")):
    s = Gh[(Gh.niveau_capex == "rte_ref") & (Gh.gaz == 30) & (Gh.avec_cavite == cav)].groupby("obligation").surcout_hors_taxe_meur_an.mean()
    ax.plot(100 * s.index, s.values, color=c, label="with cavern" if cav else "without cavern")
    po = O[(O.niveau_capex == "rte_ref") & (O.avec_cavite == cav)].groupby("obligation").surcout_hors_taxe_meur_an.mean()
    ax.scatter(100 * po.index, po.values, facecolor="white", edgecolor=c, zorder=5)
ax.axvline(100 * (1 - fc.CHLORE_MW / fc.DEMANDE_FOS_MW), ls="--", color="grey")
ax.set(xlabel="mandated electrolytic share (%)", ylabel="extra cost excl. carbon tax (€m/yr)", ylim=(0, None))
ax.legend(frameon=False); ax.grid(ls=":")
No description has been provided for this image
In [11]:
# Steps of the mandate: marginal abatement cost (€/tCO2), reduced model, 1,350 €/kW_e
m = analyse.marches(Gh[Gh.niveau_capex == "rte_ref"], ["gaz", "avec_cavite"])
m.pivot_table(index="obligation", columns=["gaz", "avec_cavite"], values="abattement_marginal_eur_t").round(0)
Out[11]:
gaz 15 30 65
avec_cavite False True False True False True
obligation
0.15 187.0 187.0 153.0 153.0 NaN NaN
0.30 187.0 187.0 154.0 154.0 217.0 106.0
0.45 195.0 195.0 205.0 177.0 258.0 106.0
0.60 220.0 209.0 265.0 174.0 458.0 110.0
0.70 222.0 209.0 274.0 180.0 401.0 154.0
0.75 222.0 210.0 274.0 195.0 398.0 227.0
0.80 222.0 214.0 274.0 207.0 398.0 246.0
0.86 265.0 240.0 336.0 230.0 416.0 337.0
0.90 NaN 4794.0 NaN 3215.0 458.0 758.0
0.95 5016.0 NaN 2286.0 NaN 1405.0 2826.0
1.00 4231.0 8214.0 8829.0 NaN 13847.0 NaN
In [12]:
# Same at the POMMES points (gas 30), average and marginal abatement cost
analyse._arrondi(analyse.marches(O, ["niveau_capex", "avec_cavite"]))
Out[12]:
niveau_capex avec_cavite obligation surcout_hors_taxe_meur_an evite_kt marche_meur_an marche_evite_kt abattement_marginal_eur_t abattement_moyen_eur_t
0 rte_favorable False 0.00 -9.7 -118.2 NaN NaN NaN NaN
1 rte_favorable False 0.30 2.3 -17.1 12.0 101.1 118.6 NaN
2 rte_favorable False 0.45 22.7 86.0 20.4 103.1 197.9 264.2
3 rte_favorable False 0.60 55.7 215.8 33.0 129.8 254.3 258.2
4 rte_favorable False 0.75 90.4 350.5 34.7 134.7 257.3 257.9
5 rte_favorable False 0.90 123.8 448.6 33.4 98.1 340.9 276.0
6 rte_favorable False 1.00 156.6 458.4 32.8 9.8 3354.1 341.7
7 rte_favorable True 0.00 0.0 0.0 NaN NaN NaN NaN
8 rte_favorable True 0.30 11.8 101.1 11.8 101.1 116.3 116.3
9 rte_favorable True 0.45 23.4 185.3 11.6 84.1 138.4 126.4
10 rte_favorable True 0.60 35.4 271.0 12.0 85.7 139.6 130.5
11 rte_favorable True 0.75 50.3 367.1 14.9 96.1 155.2 137.0
12 rte_favorable True 0.90 73.9 458.4 23.6 91.2 258.7 161.2
13 rte_favorable True 1.00 98.2 458.4 24.3 0.0 NaN 214.2
14 rte_ref False 0.00 0.0 0.0 NaN NaN NaN NaN
15 rte_ref False 0.30 30.1 196.0 30.1 196.0 153.7 153.7
16 rte_ref False 0.45 52.9 307.2 22.8 111.2 204.6 172.2
17 rte_ref False 0.60 88.6 441.9 35.7 134.7 265.2 200.5
18 rte_ref False 0.75 125.5 576.6 36.9 134.7 274.1 217.7
19 rte_ref False 0.90 162.3 670.7 36.7 94.0 390.8 242.0
20 rte_ref False 1.00 196.8 679.6 34.5 9.0 3853.0 289.5
21 rte_ref True 0.00 0.0 0.0 NaN NaN NaN NaN
22 rte_ref True 0.30 30.1 196.0 30.1 196.0 153.7 153.7
23 rte_ref True 0.45 49.8 307.2 19.7 111.2 176.9 162.1
24 rte_ref True 0.60 73.3 441.9 23.5 134.7 174.1 165.8
25 rte_ref True 0.75 98.0 575.5 24.7 133.6 185.1 170.3
26 rte_ref True 0.90 128.2 679.6 30.2 104.1 290.5 188.7
27 rte_ref True 1.00 156.4 679.6 28.1 0.0 NaN 230.1
28 these False 0.00 -20.1 -410.8 NaN NaN NaN NaN
29 these False 0.30 -15.9 -385.8 4.3 25.0 171.1 NaN
30 these False 0.45 4.7 -303.3 20.6 82.5 249.2 NaN
31 these False 0.60 39.5 -182.8 34.8 120.5 288.8 NaN
32 these False 0.75 78.9 -48.1 39.4 134.7 292.3 NaN
33 these False 0.90 118.8 81.5 39.9 129.5 307.9 1457.9
34 these False 1.00 155.3 96.1 36.5 14.6 2495.6 1615.9
35 these True 0.00 0.0 0.0 NaN NaN NaN NaN
36 these True 0.30 0.0 0.0 0.0 0.0 NaN NaN
37 these True 0.45 -0.0 -0.0 -0.0 -0.0 NaN NaN
38 these True 0.60 0.0 -0.0 0.0 -0.0 NaN NaN
39 these True 0.75 3.1 17.3 3.1 17.3 181.9 181.9
40 these True 0.90 20.5 80.2 17.3 62.9 275.3 255.2
41 these True 1.00 43.0 96.1 22.5 15.9 1417.8 447.2

6. What this case does not say — and how to go further¶

  • Prices are exogenous: the electrolyser does not move them. The cavern's cap (200 GWh) is declared, not geological; lifted, POMMES builds much more, so the value here is a lower bound.
  • The hub is isolated: a hydrogen network (to Lyon, Spain) would change the value of local storage.
  • The EU hourly-correlation rule for renewable hydrogen (RFNBO) is not modelled.
  • One European power system (2030); the weather years change, the fleet does not. Replaying the case with the TYNDP 2026 scenarios is a natural next step.

These are the kind of mini-projects a group carries out during the course.