Balancing Mechanism Skip Rates

Samuel Hinton on 2023-07-31

Senior Data Scientist Consultant

Maddie Whittaker on 2023-07-31

Data Analyst

The under utilisation of batteries in the Balancing Mechanism (BM) has become a hot topic after an open letter about BM skip rates for battery units was sent to Fintan Slye (Executive Director of National Grid ESO) on behalf of the Electricity Storage Network (ESN). As Arenko contributed to the analysis included, we feel that a deeper dive may provide some context and data driven insights for all curious parties.

Summary

  • Battery units are a valuable resource within the BM: they can charge and discharge almost instantly and are often the cheapest source of energy available in the BM.
  • Despite this, battery units are often skipped in favour of more expensive and carbon intensive units. Skipping being defined as not being accepted despite offering a more attractive price to grid than the top price accepted that settlement period.
  • We have however seen a reduction in skip rate for the UK battery storage fleet this year, from nearly 100% at the start of the year, to 80% in June, back up to 90% in July. This shift reflects the improvements National Grid ESO have made in the control room; a dedicated battery zone and a new dispatch tool to aid in sending out many small instructions.
  • Though it is currently not possible to calculate what the skip rate should be based on the data available, it is clear that there is still room for improvement. Arenko welcomes the opportunity to further collaborate with NG ESO to utilise batteries to their full capability within the BM, working towards the goal of a cheaper and greener grid.

Code, Data and Charts

In this section, we’ll first show the skip rates historically for 2023, and then provide a comparison of the price ranking in the BM offered by the different technology types.

To be clear, a skip rate of 0% is not something we believe is viable, nor should it be. This analysis excludes system tagged actions (which include actions taken by National Grid ESO to overcome a locational constraint), but it does not consider other constraints like inertia or voltage.

For the full methodology breakdown and the underlying data head down to the “Data Transparency” section at the end of this blog.

from datetime import timedelta
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

data = pd.read_parquet("bods.parquet").set_index(["date_start", "bmu_id", "type"]).sort_index()
data["skipped"] = data["in_merit"] & (data["accepted_volume"].isnull())
# You cant be skipped if you weren't in merit or if there were no instructions for that period
data.loc[~data["in_merit"] | data["best_price"].isnull(), "skipped"] = np.nan
# Rename NPSHYD to HYRDO and PS to PUMPED HYDRO
data["fuel_type"] = data["fuel_type"].str.replace("NPSHYD", "HYDRO").str.replace("PS", "PUMPED\nHYDRO")

Historical Skip Rates

Arenko has managed the Bloxwich asset (E_ARNKB-1) for many years. If we visualise the historical skip rate for the asset on the bid and offer, we can see that there has been significant improvement since the end of 2022, when the BM was effectively at a 100% skip rate. What you see below is a rolling 30 day window of the skip rate.

skipped = data.loc[lambda x: x["fuel_type"] == "BESS", "skipped"].unstack(level="type").unstack(level="bmu_id")
td = timedelta(days=30)
rolling = skipped.rolling(td).mean()
rolling = rolling["2022-12-01":]

fleet_offer_skip_rate = rolling["o"].mean(axis=1)
fleet_bid_skip_rate = rolling["b"].mean(axis=1)

bo, bb, fo, fb = "#047857", "#8b5cf6", "#022c22", "#4c1d95"

fig, axes = plt.subplots(nrows=2, figsize=(10, 6), sharex=True, gridspec_kw={"hspace": 0.1})
axes[0].plot(rolling.index, rolling["o"]["E_ARNKB-1"], c=bo)
axes[1].plot(rolling.index, rolling["b"]["E_ARNKB-1"], c=bb)
axes[0].plot(rolling.index, fleet_offer_skip_rate, c=fo)
axes[1].plot(rolling.index, fleet_bid_skip_rate, c=fb)

png

Please note that we have simplified the skip-rate calculation methodology in this blog so it is easier to follow without extensive BM expertise (see Data Transparency Section at the end). The historical trend remains the same with both this simplified calculation methodology and the full analysis which backed the open letter.

Unfortunately we cannot use the current data to look at previous years - it is not available in the new Elexon Insights API which we have utilised. Our internal reports from 2020 show a skip rate of as little as 50%, with a section of several weeks in September 2020 having a skip rate of only 20%. However, the market is very different today and therefore the skip rates from 2020 are likely not comparable to those seen in 2023.

Not all BODs are made equal

Aside from the carbon-impact of turning on coal or gas over greener sources, it’s often the case that larger plants, especially slower-to-turn-on coal plants, post higher prices when offering energy.

We can ask the question “Over the year to date, what’s been the average price accepted on offers for each technology type?”. The plot below shows those prices, plus the 10th and 90th percentile for the accepted prices. The average price is volume weighted. The percentiles are simply showing the percentiles of accepted prices (without volume weighting). Barring wind (more on this in a moment) batteries offer the cheapest power on average.

The size of this marker is scaled to represent the volume of each technology type. So wind had tiny volumes accepted. Batteries had 15 times as much accepted offer volume as wind, and CCGT’s had over a hundred times the accepted volume of batteries.

means = (
    data["2023-01-01":]
    .reset_index()
    .loc[lambda x: (x["accepted_volume"] > 0) & (x["type"] == "o")]
    .groupby("fuel_type")
    .apply(
        lambda x: pd.Series(
            {
                "price": np.average(x["accepted_price"], weights=x["accepted_volume"]),
                "10th_percentile": np.quantile(x["price"], 0.1),
                "90th_percentile": np.quantile(x["price"], 0.9),
                "volume": x["accepted_volume"].sum(),
            }
        )
    )
    .sort_values(by="price")
)

cmap = {
    "BESS": "#106e42",
    "PUMPED\nHYDRO": "#162e73",
    "CCGT": "#8a171b",
    "COAL": "#1c1918",
    "WIND": "#059669",
    "BIOMASS": "#523427",
    "HYDRO": "#1b59a6",
}

fig, ax = plt.subplots(figsize=(8, 5))
cs = [cmap[x] for x in means.index]

ax.errorbar(
    means.index,
    means["price"],
    yerr=[means["price"] - means["10th_percentile"], means["90th_percentile"] - means["price"]],
    fmt="o",
    c="#888888",
    lw=0.1,
    capsize=7,
    ms=0,
)

ax.scatter(means.index, means["price"], color=cs, ec=cs, s=40, linewidths=0.4, marker="+", zorder=50)
ax.scatter(means.index, means["price"], color=cs, ec="none", s=means["volume"] / 1e3, zorder=100)

png

We debated on whether we should remove wind from the above plot. Wind typically generates, trading in the GB wholesale market, and doesn’t often participate in the BM. However, during periods of extended negative prices, some wind assets may apply the brakes to their generation. In an ideal world, there would be available energy storage to import this green energy at a positive price and deliver it later in time when it is needed, and wind would never need to turn down.

To provide the above in a tabular format:

means.round().rename(
    columns={
        "price": "Average Price (£/MWh)",
        "volume": "Accepted Volume (MWh)",
        "90th_percentile": "90th Percentile Price (£/MWh)",
        "10th_percentile": "10th Percentile Price (£/MWh)",
    }
)
Average Price (£/MWh) 10th Percentile Price (£/MWh) 90th Percentile Price (£/MWh) Accepted Volume (MWh)
fuel_type
WIND 72.0 50.0 107.0 1915.0
BESS 147.0 100.0 200.0 28702.0
BIOMASS 152.0 123.0 200.0 39197.0
CCGT 171.0 115.0 215.0 2863473.0
COAL 176.0 125.0 214.0 155513.0
HYDRO 178.0 125.0 250.0 18678.0
PUMPED\nHYDRO 195.0 124.0 277.0 215727.0

This data shows that battery units, when compared to fossil fuel generators, provide a cheaper source of accepted power onto the grid.

Data Transparency

The information used in this analysis is sourced from the Elexon Insights API. Specifically, to compute what is being offered on the BM by all assets, we use the BOD endpoint and combine this with the BM Unit Reference API. As the Elexon Insights API is a new product, it does not include data consistently before October 2022.

For the BM stacks, we source this from the older BMRS DETSYSPRICES report.

For the raw BOD data, we filter BODs to in-market bids and offers with non-zero levels, and further restrict the analysis to look at the cheapest BOD per settlement period and BM unit. We note that the majority of batteries only submit a single BOD level.

For the BM stack data, we filter out undo actions, system actions, and out of market actions. These stacks are used to determine the top offer and bid price (most expensive offer, cheapest bid) that was accepted by grid. These prices are used to determine whether a BOD was in merit or not. Settlement periods with no bids or offers are excluded from the skip rate calculation.

We also remove aggregated acceptances with a volume of less than 0.1MWh as being of negligible value both to grid and to asset owners. For context, a 50MW asset would deliver 0.1MWh in under ten seconds.

The skip rate calculation methodology applied in the open letter also excluded batteries that were not participating in the BM according to their MEL/MIL/PN and removed multihour instructions (that batteries couldn’t supply). We have not considered these constraints in the blog as it significantly simplifies the skip-rate calculation methodology. The end result is that the skip rate values are slightly higher in the blog than in the open letter but the historical trend remains the same.

Finally, we’ve tagged numerous known BESS assets with the “BESS” fuel type and dropped the miscellanous “OTHER” category, along with “OCGT” due to data sparsity.

You can download the csv file here, and the last five rows of the data are shown below for context:

data.reset_index().set_index("date_start").tail(5)
bmu_id type level price best_price in_merit accepted_volume accepted_price fuel_type skipped
date_start
2023-07-30 23:30:00+00:00 T_WLNYO-4 b -329 -196.65 -199.9 True NaN NaN WIND True
2023-07-30 23:30:00+00:00 T_WLNYW-1 b -182 -176.13 -199.9 True NaN NaN WIND True
2023-07-30 23:30:00+00:00 T_WTMSO-1 b -200 -173.95 -199.9 True NaN NaN WIND True
2023-07-30 23:30:00+00:00 V__GHABI001 b -6 21.00 -199.9 True NaN NaN BESS True
2023-07-30 23:30:00+00:00 V__GHABI001 o 5 104.00 135.0 True NaN NaN BESS True

To explain the bottom two rows from the above, the unit V__GHABI001 (which is Gresham’s 49MW Red Scar battery optimised by Habitat) offered to supply energy to the grid at £104/MWh, but was not accepted despite grid paying £135/MWh. It also offered to take energy off the grid, paying £21/MWh for the energy it took. This offer also was not taken, with grid in the end paying up £199/MWh for another asset to import the energy. That’s a £220/MWh difference in spread on the bid. Of course, there could be very real system constraints that prevented this specific bid and offer from being accepted.


For your convenience, here’s the code in one block:

from datetime import timedelta
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

data = pd.read_parquet("bods.parquet").set_index(["date_start", "bmu_id", "type"]).sort_index()
data["skipped"] = data["in_merit"] & (data["accepted_volume"].isnull())
# You cant be skipped if you weren't in merit or if there were no instructions for that period
data.loc[~data["in_merit"] | data["best_price"].isnull(), "skipped"] = np.nan
# Rename NPSHYD to HYRDO and PS to PUMPED HYDRO
data["fuel_type"] = data["fuel_type"].str.replace("NPSHYD", "HYDRO").str.replace("PS", "PUMPED\nHYDRO")
skipped = data.loc[lambda x: x["fuel_type"] == "BESS", "skipped"].unstack(level="type").unstack(level="bmu_id")
td = timedelta(days=30)
rolling = skipped.rolling(td).mean()
rolling = rolling["2022-12-01":]

fleet_offer_skip_rate = rolling["o"].mean(axis=1)
fleet_bid_skip_rate = rolling["b"].mean(axis=1)

bo, bb, fo, fb = "#047857", "#8b5cf6", "#022c22", "#4c1d95"

fig, axes = plt.subplots(nrows=2, figsize=(10, 6), sharex=True, gridspec_kw={"hspace": 0.1})
axes[0].plot(rolling.index, rolling["o"]["E_ARNKB-1"], c=bo)
axes[1].plot(rolling.index, rolling["b"]["E_ARNKB-1"], c=bb)
axes[0].plot(rolling.index, fleet_offer_skip_rate, c=fo)
axes[1].plot(rolling.index, fleet_bid_skip_rate, c=fb)

means = (
    data["2023-01-01":]
    .reset_index()
    .loc[lambda x: (x["accepted_volume"] > 0) & (x["type"] == "o")]
    .groupby("fuel_type")
    .apply(
        lambda x: pd.Series(
            {
                "price": np.average(x["accepted_price"], weights=x["accepted_volume"]),
                "10th_percentile": np.quantile(x["price"], 0.1),
                "90th_percentile": np.quantile(x["price"], 0.9),
                "volume": x["accepted_volume"].sum(),
            }
        )
    )
    .sort_values(by="price")
)

cmap = {
    "BESS": "#106e42",
    "PUMPED\nHYDRO": "#162e73",
    "CCGT": "#8a171b",
    "COAL": "#1c1918",
    "WIND": "#059669",
    "BIOMASS": "#523427",
    "HYDRO": "#1b59a6",
}

fig, ax = plt.subplots(figsize=(8, 5))
cs = [cmap[x] for x in means.index]

ax.errorbar(
    means.index,
    means["price"],
    yerr=[means["price"] - means["10th_percentile"], means["90th_percentile"] - means["price"]],
    fmt="o",
    c="#888888",
    lw=0.1,
    capsize=7,
    ms=0,
)

ax.scatter(means.index, means["price"], color=cs, ec=cs, s=40, linewidths=0.4, marker="+", zorder=50)
ax.scatter(means.index, means["price"], color=cs, ec="none", s=means["volume"] / 1e3, zorder=100)

means.round().rename(
    columns={
        "price": "Average Price (£/MWh)",
        "volume": "Accepted Volume (MWh)",
        "90th_percentile": "90th Percentile Price (£/MWh)",
        "10th_percentile": "10th Percentile Price (£/MWh)",
    }
)
data.reset_index().set_index("date_start").tail(5)