How to avoid Triads

Samuel Hinton on 2023-02-20

Senior Data Scientist Consultant

With the winter season finally coming to end, we look back at one of the most dreaded words for battery owners: Triads!

But first, what are Triads?

Triads are the three half-hour settlement periods of the year which have the highest system demand. They fall between November and February, and must be separated by 10 days. Triads are only known after the winter season is over and they are used to determine Transmission Network Use of System (TNUoS) charges for the next year (see ESO’s documentation).

Assets which import energy during a Triad are charged a demand tariff which varies based on the asset’s geographical location. These tariffs are large. For the Bloxwich asset we manage, the 2022/23 values were 57.2 £/kW. Hence, if we charged 20MW during a single Triad, we would incur almost £400k in cost just from that one settlement period!

However, unlike assets such as factories, batteries can’t always avoid charging during Triad-risk periods. This is because batteries are often contracted in frequency services (to balance the grid). The challenge for battery owners is to minimise exposure to Triads.

When do Triads typically occur?

To aid in avoiding triads, we pulled historical data. Triad data is exposed in the BMRS and also by ESO. For convenience though, we’ve consolidated these documents into a single CSV file you can download here. The latest nine rows are shown below.

from datetime import timezone as tz
from datetime import timedelta as td
from zoneinfo import ZoneInfo

import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from mpl_toolkits.axes_grid1 import make_axes_locatable
from scipy.ndimage import gaussian_filter1d


# There's no offset this time of year, but good practise to
# handle timezones properly
london = ZoneInfo("Europe/London")
df = pd.read_csv("triads.csv", parse_dates=["Start Time"])
df["Start Time"] = df["Start Time"].dt.tz_convert(london)
df.head(9)
Start Time Date Settlement Period Demand (MW)
0 2022-01-20 17:00:00+00:00 2022-01-20 35 43538
1 2022-01-05 17:00:00+00:00 2022-01-05 35 42760
2 2021-12-02 16:30:00+00:00 2021-12-02 34 43748
3 2021-02-10 18:00:00+00:00 2021-02-10 37 44997
4 2021-01-07 17:30:00+00:00 2021-01-07 36 45450
5 2020-12-07 17:00:00+00:00 2020-12-07 35 44449
6 2019-12-17 16:30:00+00:00 2019-12-17 34 43546
7 2019-12-02 17:00:00+00:00 2019-12-02 35 44160
8 2019-11-18 17:00:00+00:00 2019-11-18 35 44308

We can visualise this, and we’ll include sunset as it will be handy, but note they will be sunset times for this year, which won’t exactly line up with the sunset times for the past thirty years.

# Seriously, just data processing here, skip this cell.
dfs = pd.read_csv("sunsets.csv", parse_dates=["date"])
dfs["date"] = dfs["date"].dt.tz_localize(tz.utc).dt.tz_convert(london)
dfs["sunset_float"] = (
    dfs["Sunset"].str.split(":", expand=True)[0].astype(float)
    + dfs["Sunset"].str.split(":", expand=True)[1].astype(float) / 60
)
dfs["dusk_float"] = (
    dfs["Dusk"].str.split(":", expand=True)[0].astype(float)
    + dfs["Dusk"].str.split(":", expand=True)[1].astype(float) / 60
)

# The data we have is a bit noisy, so a super small smooth will make it look nicer.
dfs["sunset_float"] = gaussian_filter1d(dfs["sunset_float"], 2, mode="nearest")
dfs["dusk_float"] = gaussian_filter1d(dfs["dusk_float"], 2, mode="nearest")
dfs = dfs.iloc[:-4]


# Create a start time all in the same year, and turn the time into an hours float for plotting
df["Date2022"] = df["Start Time"].apply(
    lambda x: x.replace(year=2021) if x.month > 10 else x.replace(year=2022)
)
df["Time"] = df["Start Time"].dt.hour + df["Start Time"].dt.minute / 60

fig, ax = plt.subplots()

# Plot sunset, dusk and shading
ax.plot(dfs["date"], dfs["sunset_float"], label="Sunset", lw=1)
ax.plot(dfs["date"], dfs["dusk_float"], label="Dusk", lw=1)
ax.fill_between(
    dfs["date"],
    dfs["sunset_float"],
    dfs["dusk_float"],
    color="#ea580c",
    alpha=0.1,
    label="Twilight",
)

# Plot actual triads
h = ax.scatter(
    df["Date2022"],
    df["Time"],
    c=df["Demand (MW)"],
    s=df["Start Time"].dt.year - 1988,
    label="Demand",
    zorder=2,
)

# Add colorbar
cax = make_axes_locatable(ax).append_axes("right", size="3%", pad=0.1)
fig.colorbar(h, cax=cax, label="Demand (MW)")

# For fun, lets count up the number of occurences instead of adding a histogram trace
for t, v in df["Time"].value_counts().items():
    ax.annotate(
        f"({v})", (df["Date2022"].max() + td(days=7), t), ha="right", va="center", fontsize=10
    )

png

The size of the dot corresponds to how far in the past each Triad was. The smallest dots correspond to our data from 1990, and we have data up to 2022. The numbers in brackets on the right are the number of times Triads have fallen in each settlement period.

Key takeaways include:

  1. Settlement Period 35, which spans 5:00pm → 5:30pm GMT, is by far the most common time for a Triad. Out of the historical Triads we have, 78% of triads fell on this settlement period.
  2. The chance it falls earlier is increased in November and December, while the chance it falls later is greater in January and February.
  3. Triads at 6pm are incredibly uncommon, and have only ever occurred in February. There is an obvious correlation between when Triads occur and when it gets dark.

What are the key drivers of Triads?

As Triads are only defined after the winter season is over, we need a way to predict days which might be high risk. One issue we have is sample size. Going back to 1990 we only have about ninety Triads. However a Triad is really just a period of high demand. If we simply look at demand itself, we’ll have more data. We can get the data for all of the 2021/2022 Triads and investigate those trends. The demand data comes from the BMRS’s ROLSYSDEM report (detailed here), but we provide the CSV for convenience.

Combining demand and weather data, and some other features, we can visualise the Triad window. This example omits parts of November and February.

df_weather = (
    pd.read_csv("london_weather.csv.gz")
    .rename(columns={"dt_iso": "date"})
    .drop(
        columns=[
            "feels_like",
            "pressure",
            "clouds_all",
            "weather_description",
            "snow_1h",
            "snow_3h",
        ]
    )
)
df_weather["date"] = pd.to_datetime(df_weather["date"], format="%Y-%m-%d %H:%M:%S %z UTC")
df_weather = df_weather.set_index("date").sort_index(ascending=False).fillna(0)

# Create a map of triads
df["hour"] = df["Start Time"].dt.floor("60T")
df_triad = df[["hour"]].assign(triad=1).set_index("hour")
df_all = df_weather.join(df_triad)

results = []
for days in [1, 3, 7]:
    df_weather_smoothed = df_weather.rolling(24 * days).median()
    df_all = df_all.join((df_weather - df_weather_smoothed).add_suffix(f"_change_over_{days}_days"))
df_all = df_all.fillna(0)
df_all.columns = [c.replace("_", " ").title().replace("1 Days", "1 Day") for c in df_all.columns]

df_demand = pd.read_csv("demand.csv.gz", parse_dates=["date_start"], index_col="date_start")
# The report does 5min intervals, so we'll aggregate to a SP
df_demand = (
    df_demand.resample("30T").mean().rename(columns={"rolling_demand": "Rolling Demand"}) / 1e3
)

# Combine our data
dfc = df_demand.join(df.assign(Triad=1).set_index("Start Time")[["Triad"]], how="left").fillna(0)
dfc = dfc.join(df_all.drop(columns="Triad"), how="left").ffill(limit=1)
dfc = dfc["2021-11-15":"2022-02-01"]


fig, (ax, ax2) = plt.subplots(
    figsize=(12, 7), nrows=2, sharex=True, gridspec_kw={"height_ratios": [3, 1], "hspace": 0.1}
)
ax.plot(dfc.index, dfc["Rolling Demand"], label="Demand", c="#047857", lw=0.5)
triads = dfc.loc[dfc["Triad"] == 1, "Rolling Demand"]
ax.scatter(triads.index, triads, c="#f59e0b", s=10, label="Triads", zorder=2)
for t in triads.index:
    ax.axvspan(t - td(days=10), t + td(days=10), color="#fcd34d", alpha=0.2, zorder=1)
ax2.plot(dfc.index, dfc["Temp"], label="Temperature", c="#0ea5e9", lw=0.5)

png

The orange dots show the reported Triads and the shaded regions show the 10 day window in which another Triad cannot occur. Note that the BMRS data doesn’t exactly align with the reported Triads (we would have picked three entirely different peaks). This is due to some differences in the way demand is defined.

Given this, instead of correlating the demand itself, we’ll flag the top 1% of settlement periods by their demand as a “Triad risk” and see what insights that gives us. As we know Triads are also in the afternoon, we can look only at settlement periods which have a higher chance of being a Triad. Restricting our data to the hours of 4pm → 7pm (inclusive) for those few months gives us a bit under a thousand settlement periods of data, which includes the three actual Triads. Approximately forty of those settlement periods are “Triad-risk” periods (in the top 1% of demand), and we’d like to identify those.

Additionally, let’s add in some other data, for example, whether we’re looking at a weekday or weekend, the settlement period, and the day of the week. We then calculate the Spearman correlation between the features and the Triad risks. Here we are using the Spearman correlation coefficient over Pearson’s because we don’t care if the relationship is linear or not.

df_final = (
    dfc.assign(triad_risk=lambda x: x["Rolling Demand"] > x["Rolling Demand"].quantile(0.99))
    .loc[lambda x: x.index.hour.isin([16, 17, 18, 19])]
    .assign(Weekday=lambda x: x.index.dayofweek < 5)
    .assign(Weekend=lambda x: x.index.dayofweek >= 5)
    .assign(
        SP=lambda x: "SP" + (1 + 2 * (x.index.hour + x.index.minute / 60)).astype(int).astype(str)
    )
)
# Adding the one hot encoding for day of week and SP
df_final = pd.concat(
    [
        df_final.reset_index(),
        df_final.index.day_name().str.get_dummies().to_frame().reset_index(drop=True),
    ],
    axis=1,
)
df_final = pd.concat([df_final, df_final["SP"].str.get_dummies().reset_index(drop=True)], axis=1)

# Removing some of the weather features that just clutter the plot
df_final = df_final[
    [c for c in df_final.columns if "Pressure" not in c and "Wind" not in c and "Humidity" not in c]
]


corrs2 = (
    df_final.corrwith(df_final["triad_risk"], method="spearman", numeric_only=True)
    .dropna()
    .sort_values()
    .drop(labels=["Rolling Demand", "Triad", "triad_risk"])
)
fig, ax = plt.subplots(figsize=(9, 9))
corrs2.plot.barh(ax=ax, facecolor="#7c3aed")
ax.axvline(0, c="k", lw=1)
ax.set_xlabel("Spearman Correlation Coefficient");

png

An extension to this simple correlation study would be to take this data for as many years as we can gather, train something like a simple XGB model on it, and use the Shapley values to determine which are important features. From there it would be trivial to produce a basic machine learning predictor that classifies periods as “Triad risk” or not. We could similarly extend that to a regressor that predicts the probability of a Triad period.

That is, however, beyond the scope of a simple blog post.

Reading over the plot above, if someone was concerned about charging on a Triad:

  1. Don’t charge when it’s colder than normal, especially if this is a cold snap.
  2. You don’t have to worry as much if it’s a weekend, but you do have to worry more if it’s Thursday or Monday (2/3 Triads for 2021/2022 were on a Thursday).
  3. Settlement periods 34-37 (4:30pm → 6:00pm GMT) are dangerous, later or earlier ones not as much.

Or to put this another way even more succinctly: Do not charge your asset on cold Thursdays from 4:30pm to 6:00pm.


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

from datetime import timezone as tz
from datetime import timedelta as td
from zoneinfo import ZoneInfo

import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from mpl_toolkits.axes_grid1 import make_axes_locatable
from scipy.ndimage import gaussian_filter1d


# There's no offset this time of year, but good practise to
# handle timezones properly
london = ZoneInfo("Europe/London")
df = pd.read_csv("triads.csv", parse_dates=["Start Time"])
df["Start Time"] = df["Start Time"].dt.tz_convert(london)
df.head(9)
# Seriously, just data processing here, skip this cell.
dfs = pd.read_csv("sunsets.csv", parse_dates=["date"])
dfs["date"] = dfs["date"].dt.tz_localize(tz.utc).dt.tz_convert(london)
dfs["sunset_float"] = (
    dfs["Sunset"].str.split(":", expand=True)[0].astype(float)
    + dfs["Sunset"].str.split(":", expand=True)[1].astype(float) / 60
)
dfs["dusk_float"] = (
    dfs["Dusk"].str.split(":", expand=True)[0].astype(float)
    + dfs["Dusk"].str.split(":", expand=True)[1].astype(float) / 60
)

# The data we have is a bit noisy, so a super small smooth will make it look nicer.
dfs["sunset_float"] = gaussian_filter1d(dfs["sunset_float"], 2, mode="nearest")
dfs["dusk_float"] = gaussian_filter1d(dfs["dusk_float"], 2, mode="nearest")
dfs = dfs.iloc[:-4]


# Create a start time all in the same year, and turn the time into an hours float for plotting
df["Date2022"] = df["Start Time"].apply(
    lambda x: x.replace(year=2021) if x.month > 10 else x.replace(year=2022)
)
df["Time"] = df["Start Time"].dt.hour + df["Start Time"].dt.minute / 60

fig, ax = plt.subplots()

# Plot sunset, dusk and shading
ax.plot(dfs["date"], dfs["sunset_float"], label="Sunset", lw=1)
ax.plot(dfs["date"], dfs["dusk_float"], label="Dusk", lw=1)
ax.fill_between(
    dfs["date"],
    dfs["sunset_float"],
    dfs["dusk_float"],
    color="#ea580c",
    alpha=0.1,
    label="Twilight",
)

# Plot actual triads
h = ax.scatter(
    df["Date2022"],
    df["Time"],
    c=df["Demand (MW)"],
    s=df["Start Time"].dt.year - 1988,
    label="Demand",
    zorder=2,
)

# Add colorbar
cax = make_axes_locatable(ax).append_axes("right", size="3%", pad=0.1)
fig.colorbar(h, cax=cax, label="Demand (MW)")

# For fun, lets count up the number of occurences instead of adding a histogram trace
for t, v in df["Time"].value_counts().items():
    ax.annotate(
        f"({v})", (df["Date2022"].max() + td(days=7), t), ha="right", va="center", fontsize=10
    )


df_weather = (
    pd.read_csv("london_weather.csv.gz")
    .rename(columns={"dt_iso": "date"})
    .drop(
        columns=[
            "feels_like",
            "pressure",
            "clouds_all",
            "weather_description",
            "snow_1h",
            "snow_3h",
        ]
    )
)
df_weather["date"] = pd.to_datetime(df_weather["date"], format="%Y-%m-%d %H:%M:%S %z UTC")
df_weather = df_weather.set_index("date").sort_index(ascending=False).fillna(0)

# Create a map of triads
df["hour"] = df["Start Time"].dt.floor("60T")
df_triad = df[["hour"]].assign(triad=1).set_index("hour")
df_all = df_weather.join(df_triad)

results = []
for days in [1, 3, 7]:
    df_weather_smoothed = df_weather.rolling(24 * days).median()
    df_all = df_all.join((df_weather - df_weather_smoothed).add_suffix(f"_change_over_{days}_days"))
df_all = df_all.fillna(0)
df_all.columns = [c.replace("_", " ").title().replace("1 Days", "1 Day") for c in df_all.columns]

df_demand = pd.read_csv("demand.csv.gz", parse_dates=["date_start"], index_col="date_start")
# The report does 5min intervals, so we'll aggregate to a SP
df_demand = (
    df_demand.resample("30T").mean().rename(columns={"rolling_demand": "Rolling Demand"}) / 1e3
)

# Combine our data
dfc = df_demand.join(df.assign(Triad=1).set_index("Start Time")[["Triad"]], how="left").fillna(0)
dfc = dfc.join(df_all.drop(columns="Triad"), how="left").ffill(limit=1)
dfc = dfc["2021-11-15":"2022-02-01"]


fig, (ax, ax2) = plt.subplots(
    figsize=(12, 7), nrows=2, sharex=True, gridspec_kw={"height_ratios": [3, 1], "hspace": 0.1}
)
ax.plot(dfc.index, dfc["Rolling Demand"], label="Demand", c="#047857", lw=0.5)
triads = dfc.loc[dfc["Triad"] == 1, "Rolling Demand"]
ax.scatter(triads.index, triads, c="#f59e0b", s=10, label="Triads", zorder=2)
for t in triads.index:
    ax.axvspan(t - td(days=10), t + td(days=10), color="#fcd34d", alpha=0.2, zorder=1)
ax2.plot(dfc.index, dfc["Temp"], label="Temperature", c="#0ea5e9", lw=0.5)

df_final = (
    dfc.assign(triad_risk=lambda x: x["Rolling Demand"] > x["Rolling Demand"].quantile(0.99))
    .loc[lambda x: x.index.hour.isin([16, 17, 18, 19])]
    .assign(Weekday=lambda x: x.index.dayofweek < 5)
    .assign(Weekend=lambda x: x.index.dayofweek >= 5)
    .assign(
        SP=lambda x: "SP" + (1 + 2 * (x.index.hour + x.index.minute / 60)).astype(int).astype(str)
    )
)
# Adding the one hot encoding for day of week and SP
df_final = pd.concat(
    [
        df_final.reset_index(),
        df_final.index.day_name().str.get_dummies().to_frame().reset_index(drop=True),
    ],
    axis=1,
)
df_final = pd.concat([df_final, df_final["SP"].str.get_dummies().reset_index(drop=True)], axis=1)

# Removing some of the weather features that just clutter the plot
df_final = df_final[
    [c for c in df_final.columns if "Pressure" not in c and "Wind" not in c and "Humidity" not in c]
]


corrs2 = (
    df_final.corrwith(df_final["triad_risk"], method="spearman", numeric_only=True)
    .dropna()
    .sort_values()
    .drop(labels=["Rolling Demand", "Triad", "triad_risk"])
)
fig, ax = plt.subplots(figsize=(9, 9))
corrs2.plot.barh(ax=ax, facecolor="#7c3aed")
ax.axvline(0, c="k", lw=1)
ax.set_xlabel("Spearman Correlation Coefficient");