Market Dispatch#
This tutorial explains PyPSA-GB’s two-stage market workflow using the 2020 validation notebook as a worked example. It follows the model from the copperplate wholesale schedule through constrained balancing redispatch, then compares the result with ELEXON, NESO, and ESPENI evidence.
The notebook is intentionally analysis-first. It does not build the networks itself; it reads the market outputs produced by Snakemake and shows how to interpret them.
What This Tutorial Covers#
The market workflow answers a different question from the standard network-constrained OPF tutorials. The standard solve asks for the least-cost physical dispatch subject to network limits. The market workflow first asks what the system would schedule in an unconstrained GB wholesale market, then asks what redispatch is required once the real network constraints are restored.
By the end of the tutorial you should be able to:
identify the files produced by the wholesale and balancing stages;
explain why the wholesale price is read from the exported demand-bus price series;
compare wholesale dispatch with final physical dispatch;
interpret BM redispatch volumes, constraint costs, and congestion diagnostics;
use historical ELEXON and NESO validation outputs to judge whether a market scenario is behaving plausibly.
For configuration details, see the user guide page on market dispatch.
Before Running The Notebook#
This notebook expects the monthly 2020 validation market scenarios to have already been solved. The setup cell checks for the required outputs and raises a clear FileNotFoundError if any are missing.
The key output groups are:
File pattern |
Produced by |
Meaning |
|---|---|---|
|
|
Copperplate wholesale network |
|
|
Demand-bus wholesale SMP series |
|
|
Physical constrained network |
|
|
Asset-level BM increase/decrease volumes and costs |
|
|
ELEXON BM validation metrics |
|
|
NESO constraint validation metrics |
Typical targets are:
snakemake resources/analysis/Validation_Jan2020_UniformNetworkBaseline_bm_validation.html -j 4
snakemake resources/analysis/Validation_Jan2020_UniformNetworkBaseline_neso_validation.html -j 4
The notebook aggregates all twelve monthly validation scenarios listed in the setup cell. If you only have one month, edit MONTH_SCENARIOS and MONTH_ORDER before running the rest of the notebook.
[1]:
from functools import lru_cache
from pathlib import Path
import json
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
import plotly.io as pio
import pypsa
import yaml
from IPython.display import HTML, display
from plotly.subplots import make_subplots
ROOT = Path.cwd()
if not (ROOT / "resources" / "market").exists():
for parent in ROOT.parents:
if (parent / "resources" / "market").exists():
ROOT = parent
break
MARKET = ROOT / "resources" / "market"
DATA = ROOT / "data"
MONTH_SCENARIOS = [
"Validation_Jan2020_UniformNetworkBaseline",
"Validation_Feb2020_UniformNetworkBaseline",
"Validation_Mar2020_UniformNetworkBaseline",
"Validation_Apr2020_UniformNetworkBaseline",
"Validation_May2020_UniformNetworkBaseline",
"Validation_Jun2020_UniformNetworkBaseline",
"Validation_Jul2020_UniformNetworkBaseline",
"Validation_Aug2020_UniformNetworkBaseline",
"Validation_Sep2020_UniformNetworkBaseline",
"Validation_Oct2020_UniformNetworkBaseline",
"Validation_Nov2020_UniformNetworkBaseline",
"Validation_Dec2020_UniformNetworkBaseline",
]
MONTH_ORDER = [
"Jan 2020", "Feb 2020", "Mar 2020", "Apr 2020", "May 2020", "Jun 2020",
"Jul 2020", "Aug 2020", "Sep 2020", "Oct 2020", "Nov 2020", "Dec 2020",
]
SCENARIO_TO_PERIOD = dict(zip(MONTH_SCENARIOS, MONTH_ORDER))
SCENARIO_EXAMPLE = "Validation_Dec2020_UniformNetworkBaseline"
BOUNDARY_CASES = ["SCOTEX", "SSE-SP", "SSHARN::SSHARN_SPARSE_GATE_FIT", "ESTEX", "SEIMP", "SWALEX"]
pd.options.display.float_format = "{:,.3f}".format
pio.renderers.default = "notebook_connected"
plot_template = "plotly_white"
CARRIER_COLORS = {
"CCGT": "#4C78A8", "OCGT": "#72B7B2", "coal": "#3B3B3B",
"nuclear": "#F58518", "biomass": "#54A24B", "waste_to_energy": "#8CD17D",
"wind": "#59A14F", "solar": "#EDC948", "hydro_non_ps": "#1F77B4",
"pumped storage": "#9467BD", "Battery": "#B07AA1", "load_shedding": "#D62728",
"other": "#C7C7C7", "unknown": "#C7C7C7",
}
def gbp_m(value):
return f"GBP {value / 1e6:,.1f}m"
def ratio(value):
return f"{value:,.2f}x" if pd.notna(value) else "n/a"
def style_plot(fig, height=430):
fig.update_layout(
template=plot_template,
height=height,
margin=dict(l=60, r=30, t=70, b=55),
font=dict(size=12),
legend_title_text="",
)
return fig
def _scenario_paths(scenario):
return {
"wholesale": MARKET / f"{scenario}_wholesale.nc",
"balancing": MARKET / f"{scenario}_balancing.nc",
"wholesale_price": MARKET / f"{scenario}_wholesale_price.csv",
"boav_by_carrier": MARKET / f"{scenario}_boav_by_carrier.csv",
}
missing = [
str(path.relative_to(ROOT))
for scenario in MONTH_SCENARIOS
for key, path in _scenario_paths(scenario).items()
if key in {"wholesale", "balancing", "wholesale_price"} and not path.exists()
]
if missing:
raise FileNotFoundError("Missing solved outputs:\n" + "\n".join(missing))
@lru_cache(maxsize=4)
def load_network(path):
return pypsa.Network(path)
def wholesale_network(scenario):
return load_network(str(_scenario_paths(scenario)["wholesale"]))
def balancing_network(scenario):
return load_network(str(_scenario_paths(scenario)["balancing"]))
def _snapshot_hours(index):
index = pd.DatetimeIndex(index)
if len(index) <= 1:
return pd.Series(1.0, index=index, dtype=float)
dt = pd.Series(index=index, data=index.to_series().diff().dt.total_seconds() / 3600.0)
inferred = dt.dropna().median()
if not np.isfinite(inferred) or inferred <= 0:
inferred = 1.0
dt.iloc[0] = inferred
return dt.fillna(inferred).clip(lower=0).astype(float)
def _carrier_compare(carrier):
carrier = str(carrier)
if carrier in {"wind_onshore", "wind_offshore", "embedded_wind"}:
return "wind"
if carrier in {"solar_pv", "embedded_solar"}:
return "solar"
if carrier in {"large_hydro", "small_hydro"}:
return "hydro_non_ps"
if carrier in {"Pumped Storage Hydroelectricity", "pumped_hydro"}:
return "pumped storage"
return carrier
def _load_wholesale_price(scenario):
path = _scenario_paths(scenario)["wholesale_price"]
df = pd.read_csv(path, index_col=0, parse_dates=True)
if "wholesale_price" not in df.columns:
raise ValueError(f"{path} does not contain a wholesale_price column")
return df["wholesale_price"].astype(float).rename("Model SMP")
def _load_mid_prices():
path = MARKET / "elexon" / "mid_prices_2020.csv"
if not path.exists():
return pd.Series(dtype=float, name="ELEXON MID")
df = pd.read_csv(path, parse_dates=["datetime"])
price_col = "mid_price" if "mid_price" in df.columns else df.columns[-1]
return df.set_index("datetime")[price_col].astype(float).rename("ELEXON MID")
def _load_neso_constraint_monthly():
frames = []
csv_path = DATA / "validation" / "constraint_breakdown_2019-2020.csv"
if csv_path.exists():
frames.append(pd.read_csv(csv_path, parse_dates=["Date"]))
json_path = DATA / "validation" / "constraint_breakdown_2020_2021.json"
if json_path.exists():
payload = json.loads(json_path.read_text(encoding="utf-8-sig"))
records = payload.get("result", {}).get("records", [])
if records:
frames.append(pd.DataFrame(records).assign(Date=lambda d: pd.to_datetime(d["Date"])))
if not frames:
return pd.DataFrame(columns=["period", "neso_thermal_cost_gbp", "neso_thermal_volume_abs_mwh", "neso_thermal_volume_signed_mwh"])
df = pd.concat(frames, ignore_index=True)
df = df[(df["Date"] >= "2020-01-01") & (df["Date"] < "2021-01-01")].copy()
df["period"] = pd.Categorical(df["Date"].dt.strftime("%b %Y"), MONTH_ORDER, ordered=True)
df["Thermal constraints cost"] = pd.to_numeric(df["Thermal constraints cost"], errors="coerce").fillna(0.0)
df["Thermal constraints volume"] = pd.to_numeric(df["Thermal constraints volume"], errors="coerce").fillna(0.0)
monthly = (
df.groupby("period", observed=True)
.agg(
neso_thermal_cost_gbp=("Thermal constraints cost", "sum"),
neso_thermal_volume_signed_mwh=("Thermal constraints volume", "sum"),
)
.reset_index()
)
abs_monthly = df.assign(abs_volume=df["Thermal constraints volume"].abs()).groupby("period", observed=True)["abs_volume"].sum()
monthly["neso_thermal_volume_abs_mwh"] = monthly["period"].map(abs_monthly).astype(float)
return monthly
def _load_boav_by_carrier():
frames = []
for scenario in MONTH_SCENARIOS:
path = _scenario_paths(scenario)["boav_by_carrier"]
if not path.exists():
continue
df = pd.read_csv(path)
df["scenario"] = scenario
df["period"] = SCENARIO_TO_PERIOD[scenario]
df["carrier_compare"] = df["carrier"].map(_carrier_compare)
for col in ["increase_mwh", "decrease_mwh"]:
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0.0)
frames.append(df)
if not frames:
return pd.DataFrame(columns=["scenario", "period", "carrier", "carrier_compare", "increase_mwh", "decrease_mwh"])
out = pd.concat(frames, ignore_index=True)
out["period"] = pd.Categorical(out["period"], MONTH_ORDER, ordered=True)
return out
def _load_boundary_mapping():
path = DATA / "network" / "neso_boundary_mapping_ssesp_strict.yaml"
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh)
flat = {}
for name, info in raw.get("boundaries", {}).items():
item = dict(info)
item.setdefault("neso_boundary", name)
flat[name] = item
for sub_name, sub_info in (info.get("subconstraints") or {}).items():
sub = dict(sub_info)
sub["parent_boundary"] = name
sub["neso_boundary"] = name
flat[f"{name}::{sub_name}"] = sub
return {"boundaries": flat}
def _boundary_model_flow(network, mapping, boundary):
spec = mapping["boundaries"][boundary]
groups = spec.get("flow_groups", {}) or {}
pos = groups.get("positive", spec.get("lines", [])) or []
neg = groups.get("negative", []) or []
flow = pd.Series(0.0, index=network.snapshots)
for line in pos:
if line in network.lines_t.p0.columns:
flow = flow.add(network.lines_t.p0[line], fill_value=0.0)
for line in neg:
if line in network.lines_t.p0.columns:
flow = flow.sub(network.lines_t.p0[line], fill_value=0.0)
for link in spec.get("links", []) or []:
name = link.get("name") if isinstance(link, dict) else link
sign = float(link.get("sign", 1.0)) if isinstance(link, dict) else 1.0
if name in network.links_t.p0.columns:
flow = flow.add(sign * network.links_t.p0[name], fill_value=0.0)
return flow.abs().rename(boundary)
def _load_neso_boundary_raw():
path = DATA / "validation" / "day_ahead_constraint_flows_limits.csv"
if not path.exists():
return pd.DataFrame()
df = pd.read_csv(path, low_memory=False)
df["datetime"] = pd.to_datetime(df["Date (GMT/BST)"], errors="coerce")
df = df[(df["datetime"] >= "2020-01-01") & (df["datetime"] < "2021-01-01")].copy()
df["Limit (MW)"] = pd.to_numeric(df["Limit (MW)"], errors="coerce").abs()
df["Flow (MW)"] = pd.to_numeric(df["Flow (MW)"], errors="coerce").abs()
df["hour"] = df["datetime"].dt.floor("h")
return df
def _model_dispatch_by_carrier(network, scenario):
dt = _snapshot_hours(network.snapshots)
records = []
gen_p = network.generators_t.p
common = gen_p.columns.intersection(network.generators.index)
if len(common):
energy = gen_p.loc[:, common].clip(lower=0).mul(dt, axis=0).sum()
carriers = network.generators.loc[common, "carrier"].map(_carrier_compare)
for carrier, value in energy.groupby(carriers).sum().items():
records.append({"scenario": scenario, "period": SCENARIO_TO_PERIOD[scenario], "comparison": carrier, "model_mwh": value})
su_p = network.storage_units_t.p
common_su = su_p.columns.intersection(network.storage_units.index)
if len(common_su):
energy = su_p.loc[:, common_su].clip(lower=0).mul(dt, axis=0).sum()
carriers = network.storage_units.loc[common_su, "carrier"].map(_carrier_compare)
for carrier, value in energy.groupby(carriers).sum().items():
records.append({"scenario": scenario, "period": SCENARIO_TO_PERIOD[scenario], "comparison": carrier, "model_mwh": value})
demand_mwh = network.loads_t.p_set.mul(dt, axis=0).sum().sum()
records.append({"scenario": scenario, "period": SCENARIO_TO_PERIOD[scenario], "comparison": "model_demand_profile", "model_mwh": demand_mwh})
out = pd.DataFrame(records)
out["demand_mwh"] = demand_mwh
out["share_of_demand"] = out["model_mwh"] / demand_mwh if demand_mwh else np.nan
return out
def _redispatch_summary(wholesale, balancing, scenario):
dt = _snapshot_hours(wholesale.snapshots)
records = []
def add_component(wholesale_p, physical_p, components, component_type):
common = wholesale_p.columns.intersection(physical_p.columns).intersection(components.index)
for name in common:
diff = physical_p[name].reindex(dt.index).fillna(0.0) - wholesale_p[name].reindex(dt.index).fillna(0.0)
inc = diff.clip(lower=0).mul(dt).sum()
dec = (-diff).clip(lower=0).mul(dt).sum()
mc = pd.to_numeric(components.loc[name].get("marginal_cost", 0.0), errors="coerce")
mc = 0.0 if pd.isna(mc) else float(mc)
carrier = str(components.loc[name].get("carrier", "unknown"))
records.append({
"scenario": scenario, "period": SCENARIO_TO_PERIOD[scenario],
"component": name, "type": component_type, "carrier": carrier,
"carrier_compare": _carrier_compare(carrier),
"increase_mwh": float(inc), "decrease_mwh": float(dec),
"gross_mwh": float(inc + dec), "proxy_cost_gbp": float((inc + dec) * abs(mc)),
})
add_component(wholesale.generators_t.p, balancing.generators_t.p, balancing.generators, "generator")
if not wholesale.storage_units_t.p.empty and not balancing.storage_units_t.p.empty:
add_component(wholesale.storage_units_t.p, balancing.storage_units_t.p, balancing.storage_units, "storage_unit")
return pd.DataFrame(records)
def _load_shedding_mwh(network):
load_shedding = network.generators.index[network.generators["carrier"].astype(str).eq("load_shedding")]
if len(load_shedding) == 0:
return 0.0
cols = network.generators_t.p.columns.intersection(load_shedding)
if len(cols) == 0:
return 0.0
return float(network.generators_t.p.loc[:, cols].clip(lower=0).mul(_snapshot_hours(network.snapshots), axis=0).sum().sum())
def _load_espeni_dispatch():
path = DATA / "demand" / "espeni.csv"
if not path.exists():
return pd.DataFrame(columns=["period", "comparison", "espeni_mwh"])
header = pd.read_csv(path, nrows=0).columns
date_col = "ELEC_elex_startTime[utc](datetime)"
candidates = {
"CCGT": ["ELEC_POWER_ELEX_CCGT[MW](float32)"],
"coal": ["ELEC_POWER_ELEX_COAL[MW](float32)"],
"nuclear": ["ELEC_POWER_ELEX_NUCLEAR[MW](float32)"],
"OCGT": ["ELEC_POWER_ELEX_OCGT[MW](float32)"],
"oil": ["ELEC_POWER_ELEX_OIL[MW](float32)"],
"biomass": ["ELEC_POWER_ELEX_BIOMASS_POSTCALC[MW](float32)", "ELEC_POWER_ELEX_BIOMASS_PRECALC[MW](float32)"],
"hydro_non_ps": ["ELEC_POWER_ELEX_NPSHYD[MW](float32)"],
"pumped storage": ["ELEC_POWER_ELEX_PS_DISCHARGING[MW](float32)"],
"wind": ["ELEC_POWER_TOTAL_WIND[MW](float32)"],
"solar": ["ELEC_POWER_NGEM_EMBEDDED_SOLAR_GENERATION[MW](float32)"],
"model_demand_profile": ["ELEC_POWER_TOTAL_ESPENI[MW](float32)"],
}
col_for = {name: next((c for c in cols if c in header), None) for name, cols in candidates.items()}
usecols = [date_col] + [c for c in col_for.values() if c]
df = pd.read_csv(path, usecols=usecols, parse_dates=[date_col])
idx = pd.to_datetime(df[date_col], utc=True).dt.tz_convert(None)
df = df.set_index(idx).drop(columns=[date_col])
df = df[(df.index >= "2020-01-01") & (df.index < "2021-01-01")]
dt = _snapshot_hours(df.index)
records = []
for comparison, col in col_for.items():
if not col:
continue
series = pd.to_numeric(df[col], errors="coerce").fillna(0.0).clip(lower=0.0)
monthly = series.mul(dt, axis=0).groupby(series.index.strftime("%b %Y")).sum()
for period, value in monthly.items():
records.append({"period": period, "comparison": comparison, "espeni_mwh": value})
out = pd.DataFrame(records)
out["period"] = pd.Categorical(out["period"], MONTH_ORDER, ordered=True)
return out
mid_prices = _load_mid_prices()
neso_constraints = _load_neso_constraint_monthly()
thermal_costs = neso_constraints.set_index("period")["neso_thermal_cost_gbp"]
thermal_volumes = neso_constraints.set_index("period")["neso_thermal_volume_abs_mwh"]
boav_by_carrier = _load_boav_by_carrier()
boundary_mapping = _load_boundary_mapping()
neso_boundary_raw = _load_neso_boundary_raw()
espeni_dispatch = _load_espeni_dispatch()
score_records = []
dispatch_parts = []
redispatch_parts = []
wholesale_price_parts = []
model_boundary_parts = {boundary: [] for boundary in BOUNDARY_CASES}
for scenario in MONTH_SCENARIOS:
wholesale = wholesale_network(scenario)
balancing = balancing_network(scenario)
period = SCENARIO_TO_PERIOD[scenario]
price = _load_wholesale_price(scenario)
wholesale_price_parts.append(price)
mid_aligned = mid_prices.reindex(price.index).interpolate(limit_direction="both") if len(mid_prices) else pd.Series(dtype=float)
redispatch_month = _redispatch_summary(wholesale, balancing, scenario)
redispatch_parts.append(redispatch_month)
dispatch_parts.append(_model_dispatch_by_carrier(balancing, scenario))
for boundary in BOUNDARY_CASES:
model_boundary_parts[boundary].append(_boundary_model_flow(balancing, boundary_mapping, boundary))
score_records.append({
"scenario": scenario, "period": period,
"bm_cost_gbp": redispatch_month["proxy_cost_gbp"].sum(),
"gross_redispatch_mwh": redispatch_month["gross_mwh"].sum(),
"increase_mwh": redispatch_month["increase_mwh"].sum(),
"decrease_mwh": redispatch_month["decrease_mwh"].sum(),
"model_one_sided_redispatch_mwh": max(redispatch_month["increase_mwh"].sum(), redispatch_month["decrease_mwh"].sum()),
"load_shedding_mwh": _load_shedding_mwh(balancing),
"mean_smp_gbp_mwh": price.mean(),
"mean_mid_gbp_mwh": mid_aligned.reindex(price.index).mean() if len(mid_aligned) else np.nan,
"neso_thermal_cost_gbp": thermal_costs.get(period, np.nan),
"neso_thermal_volume_abs_mwh": thermal_volumes.get(period, np.nan),
})
score = pd.DataFrame(score_records)
score["period"] = pd.Categorical(score["period"], MONTH_ORDER, ordered=True)
score = score.sort_values("period").reset_index(drop=True)
score["bm_cost_gbp_m"] = score["bm_cost_gbp"] / 1e6
score["neso_thermal_cost_gbp_m"] = score["neso_thermal_cost_gbp"] / 1e6
score["bm_vs_neso_thermal_ratio"] = score["bm_cost_gbp"] / score["neso_thermal_cost_gbp"].replace(0, np.nan)
score["smp_mid_ratio"] = score["mean_smp_gbp_mwh"] / score["mean_mid_gbp_mwh"].replace(0, np.nan)
redispatch = pd.concat(redispatch_parts, ignore_index=True)
dispatch = pd.concat(dispatch_parts, ignore_index=True)
wholesale_price_series = pd.concat(wholesale_price_parts).sort_index()
model_boundary_flows = {boundary: pd.concat(parts).sort_index() for boundary, parts in model_boundary_parts.items() if parts}
boav_monthly = (
boav_by_carrier.groupby("period", as_index=False, observed=True)[["increase_mwh", "decrease_mwh"]].sum()
if not boav_by_carrier.empty
else pd.DataFrame(columns=["period", "increase_mwh", "decrease_mwh"])
)
boundary_records = []
for scenario in MONTH_SCENARIOS:
period = SCENARIO_TO_PERIOD[scenario]
for boundary in BOUNDARY_CASES:
spec = boundary_mapping["boundaries"][boundary]
neso_boundary = spec.get("neso_boundary", boundary)
model = model_boundary_flows[boundary]
model_month = model.loc[model.index.to_period("M") == pd.Period(period)]
nb = neso_boundary_raw[neso_boundary_raw["Constraint Group"] == neso_boundary]
nb = nb[nb["hour"].dt.to_period("M") == pd.Period(period)]
hourly = nb.groupby("hour")[["Limit (MW)", "Flow (MW)"]].mean() if not nb.empty else pd.DataFrame()
if hourly.empty or model_month.empty:
boundary_records.append({"scenario": scenario, "period": period, "boundary": boundary, "neso_boundary": neso_boundary})
continue
model_aligned = model_month.reindex(hourly.index).interpolate(limit_direction="both")
limits = hourly["Limit (MW)"].where(lambda s: s < 50000).dropna()
model_limited = model_aligned.reindex(limits.index).dropna()
limit_aligned = limits.reindex(model_limited.index)
loading = model_limited / limit_aligned.replace(0, np.nan)
exceed = (model_limited - limit_aligned).clip(lower=0)
boundary_records.append({
"scenario": scenario, "period": period, "boundary": boundary, "neso_boundary": neso_boundary,
"model_mean_flow_mw": model_month.mean(), "model_max_flow_mw": model_month.max(),
"neso_mean_flow_mw": hourly["Flow (MW)"].mean(), "neso_max_flow_mw": hourly["Flow (MW)"].max(),
"neso_mean_limit_mw": limits.mean(), "model_boundary_mean_loading": loading.mean(),
"model_boundary_hours_above_90": int((loading >= 0.9).sum()),
"model_boundary_limit_exceed_hours": int((exceed > 1e-6).sum()),
"model_boundary_max_limit_exceed_mw": exceed.max() if len(exceed) else 0.0,
"flow_ratio": model_month.mean() / hourly["Flow (MW)"].mean() if hourly["Flow (MW)"].mean() else np.nan,
})
boundaries = pd.DataFrame(boundary_records)
boundaries["period"] = pd.Categorical(boundaries["period"], MONTH_ORDER, ordered=True)
boundaries = boundaries.sort_values(["period", "boundary"]).reset_index(drop=True)
dispatch_compare = dispatch.merge(espeni_dispatch, on=["period", "comparison"], how="left")
dispatch_compare["model_espeni_ratio"] = dispatch_compare["model_mwh"] / dispatch_compare["espeni_mwh"].replace(0, np.nan)
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Jan2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Jan2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Feb2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Feb2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Mar2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Mar2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Apr2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Apr2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_May2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_May2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Jun2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Jun2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Jul2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Jul2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Aug2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Aug2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Sep2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Sep2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Oct2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Oct2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Nov2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Nov2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Dec2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
INFO:pypsa.network.io:New version 1.2.1 available! (Current: 1.0.7)
INFO:pypsa.network.io:Imported network 'Validation_Dec2020_UniformNetworkBaseline (Full)' has buses, carriers, generators, lines, links, loads, storage_units, transformers
1. Method and Raw Data Sources#
Two-Step Dispatch Method#
The validation uses a two-stage market approximation.
Wholesale day-ahead dispatch solves GB as a copperplate system. Transmission limits are relaxed, historical interconnector positions are fixed, and the model finds the least-cost dispatch position for generators and storage. This stage produces the wholesale schedule and the reported system marginal price (SMP).
Balancing mechanism redispatch restores the transmission network and NESO boundary constraints. The balancing solve is anchored to the wholesale schedule, so assets can move up or down from their wholesale positions to manage network constraints. The difference between balancing dispatch and wholesale dispatch is reported as model BM redispatch volume.
This is intended to test whether the model produces credible market and network outcomes, not to reproduce the full GB settlement process.
Raw Data Sources#
Area |
Raw source |
Use in the model or validation |
|---|---|---|
Transmission network |
NESO Electricity Ten Year Statement (ETYS) network data |
GB transmission topology, substations, lines, transformers, voltage levels, and network areas. |
Network constraints |
NESO day-ahead boundary flow and limit publications |
Validation benchmark for boundary utilisation and limit exceedance, including SSE-SP, SCOTEX, SSHARN, ESTEX, SEIMP, and SWALEX. |
Thermal fleet |
DESNZ Digest of UK Energy Statistics (DUKES), NESO TEC register, and BM Unit reference mapping where available |
Historical thermal station capacities, technologies, locations, and mapping to the transmission network. |
Renewable and storage assets |
DESNZ Renewable Energy Planning Database (REPD) |
Site-level renewable and storage capacity, status, location, technology classification, and spatial allocation. |
Renewable availability |
ERA5 weather data processed through the PyPSA/atlite renewable-profile workflow |
Weather-driven wind and solar availability profiles, with monthly performance factors used for the validation runs. |
Demand and physical generation |
ESPENI |
Historical demand, embedded wind/solar, interconnector flows, and observed technology-level physical dispatch used for end-of-notebook validation. |
Interconnector capacities and locations |
DESNZ DUKES and NESO Interconnector Register |
Built interconnector capacities, connection points, commissioning status, and mapping to GB buses. |
Wholesale price benchmark |
ELEXON Market Index Data (MID) |
Observed wholesale price benchmark for model SMP. |
Balancing action benchmark |
ELEXON BOAV accepted bid/offer volumes |
Observed accepted BM turn-up and turn-down volumes, used by carrier where mappings are available. |
Constraint cost and volume benchmark |
NESO thermal constraint cost and volume publications |
System-level benchmark for total thermal constraint cost and volume; this is not carrier-resolved. |
Benchmark Interpretation#
The validation uses several public datasets that do not measure exactly the same operational concept.
Evidence stream |
What it checks |
Main caveat |
|---|---|---|
SMP vs ELEXON MID |
Wholesale price level and time-series shape |
MID is an observed traded-price benchmark, while SMP is a model marginal price from the copperplate wholesale solve. |
Model redispatch vs BOAV |
BM movement scale and carrier composition |
BOAV is accepted bid/offer action volume; mapping to model carriers is only as good as the BMU-to-asset mapping. |
Model redispatch vs NESO thermal volume |
System-level constraint-management scale |
NESO thermal volume is not carrier-resolved, so it is not used in the carrier plot. |
Boundary flows vs NESO limits |
Spatial plausibility of network congestion |
Boundary limits are operational limits, not static asset ratings; exceedances flag periods needing closer review. |
Physical dispatch vs ESPENI |
Final technology-level generation plausibility |
ESPENI includes embedded wind and solar, so this is a dispatch comparison rather than a renewable-availability calibration target. |
The BM cost shown here is a redispatch cost indicator reconstructed from exported network dispatch and marginal-cost data. It is useful for directional comparison, but it is not a settlement-grade reconstruction of the full ELEXON bid/offer ladder objective.
How To Read The Results#
Treat the plots as a consistency check across three levels of evidence:
The wholesale section checks whether the unconstrained schedule and SMP are credible before physical network limits are applied.
The BM section checks how much redispatch the model needs once network constraints are restored, and which carriers provide that movement.
The boundary and ESPENI sections check whether the physical dispatch is plausible against independent operational data.
A mismatch in one chart is not automatically a model failure. For example, high redispatch can be consistent with a copperplate schedule that overuses generation behind a real transmission boundary. The useful signal is whether the wholesale schedule, congestion pattern, redispatch mix, and final physical dispatch tell a coherent story.
2. High-Level Validation Results#
This section is the executive view. The KPI cards summarise the model against observed 2020 benchmarks, and the overview chart keeps the market and network evidence together before the detailed sections.
For review, the most important checks are directional: whether the model has the right order of magnitude for redispatch, whether the price comparison is distorted by internal network artefacts, and whether boundary congestion appears in plausible locations and seasons.
[2]:
boav_total_inc = boav_monthly["increase_mwh"].sum() if len(boav_monthly) else np.nan
boav_total_dec = boav_monthly["decrease_mwh"].sum() if len(boav_monthly) else np.nan
kpis = {
"Months covered": f"{len(score):.0f}",
"Model one-sided redispatch": f"{score['model_one_sided_redispatch_mwh'].sum() / 1000:,.0f} GWh",
"BOAV increase volume": f"{boav_total_inc / 1000:,.0f} GWh",
"BOAV decrease volume": f"{boav_total_dec / 1000:,.0f} GWh",
"NESO thermal volume": f"{score['neso_thermal_volume_abs_mwh'].sum() / 1000:,.0f} GWh",
"Load shedding": f"{score['load_shedding_mwh'].sum():,.1f} MWh",
"Mean SMP / MID": ratio(score["smp_mid_ratio"].mean()),
}
cards = "".join(
f"""
<div style="border:1px solid #d9dee7;border-radius:6px;padding:14px 16px;background:#fbfcfe">
<div style="font-size:12px;color:#5d6778;text-transform:uppercase;letter-spacing:.04em">{name}</div>
<div style="font-size:24px;font-weight:650;color:#172033;margin-top:4px">{value}</div>
</div>
"""
for name, value in kpis.items()
)
display(HTML(f'<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">{cards}</div>'))
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
vertical_spacing=0.12,
subplot_titles=(
"System-level BM volume benchmarks",
"Monthly wholesale price fit",
),
)
fig.add_bar(
row=1,
col=1,
x=score["period"].astype(str),
y=score["model_one_sided_redispatch_mwh"] / 1000,
name="Model one-sided redispatch",
marker_color="#345995",
)
fig.add_bar(
row=1,
col=1,
x=score["period"].astype(str),
y=score["neso_thermal_volume_abs_mwh"] / 1000,
name="NESO thermal constraint volume",
marker_color="#E26D5A",
)
fig.add_scatter(
row=2,
col=1,
x=score["period"].astype(str),
y=score["mean_smp_gbp_mwh"],
mode="lines+markers",
name="Model SMP",
line=dict(color="#345995", width=3),
)
fig.add_scatter(
row=2,
col=1,
x=score["period"].astype(str),
y=score["mean_mid_gbp_mwh"],
mode="lines+markers",
name="ELEXON MID",
line=dict(color="#E26D5A", width=3),
)
fig.update_layout(title="Validation Overview", barmode="group")
fig.update_yaxes(row=1, col=1, title_text="GWh")
fig.update_yaxes(row=2, col=1, title_text="GBP/MWh")
style_plot(fig, height=720).show()
3. Wholesale Market Evidence#
The wholesale section checks the copperplate dispatch before network constraints are restored. The merit-order chart is a stack and marginal-cost diagnostic only; it deliberately does not include a mean SMP reference line. SMP is compared to ELEXON MID in the price-ratio and time-series charts.
The SMP series comes from the exported wholesale-price output rather than a raw average of all bus marginal prices, because internal/link buses can carry artefactual prices that are not representative of the GB demand-bus wholesale price.
[3]:
def plot_merit_order(scenario=SCENARIO_EXAMPLE):
network = wholesale_network(scenario)
gen = network.generators.copy()
gen = gen[(gen["p_nom"] > 0) & (gen["carrier"] != "load_shedding")].copy()
snapshots = network.snapshots
static_pmax = gen["p_max_pu"].reindex(gen.index).fillna(1.0).astype(float)
pmax = pd.DataFrame(
np.tile(static_pmax.values, (len(snapshots), 1)),
index=snapshots,
columns=gen.index,
)
if not network.generators_t.p_max_pu.empty:
tv_cols = [c for c in gen.index if c in network.generators_t.p_max_pu.columns]
pmax.loc[:, tv_cols] = network.generators_t.p_max_pu.reindex(
snapshots,
columns=tv_cols,
).fillna(1.0)
gen["available_mw"] = gen["p_nom"] * pmax.mean(axis=0).reindex(gen.index).fillna(1.0)
gen = gen[gen["available_mw"] > 1.0].copy()
gen["marginal_cost"] = pd.to_numeric(gen["marginal_cost"], errors="coerce").fillna(0.0)
gen["carrier_plot"] = gen["carrier"].map(_carrier_compare).where(
gen["carrier"].map(_carrier_compare).isin(CARRIER_COLORS),
"other",
)
gen["mc_bin"] = gen["marginal_cost"].round(1)
blocks = (
gen.groupby(["carrier_plot", "mc_bin"], as_index=False)["available_mw"]
.sum()
.rename(columns={"carrier_plot": "carrier", "mc_bin": "marginal_cost"})
.sort_values(["marginal_cost", "carrier"])
)
blocks["left_gw"] = blocks["available_mw"].cumsum().shift(fill_value=0) / 1000.0
blocks["width_gw"] = blocks["available_mw"] / 1000.0
blocks["center_gw"] = blocks["left_gw"] + blocks["width_gw"] / 2.0
blocks["base"] = np.minimum(blocks["marginal_cost"], 0.0)
blocks["height"] = blocks["marginal_cost"].abs().clip(lower=1.0)
served = network.loads_t.p_set.sum(axis=1) / 1000.0
fig = go.Figure()
for carrier, group in blocks.groupby("carrier", sort=False):
fig.add_bar(
x=group["center_gw"],
y=group["height"],
width=group["width_gw"],
base=group["base"],
name=carrier,
marker=dict(color=CARRIER_COLORS.get(carrier, CARRIER_COLORS["other"]), line=dict(width=0)),
customdata=group["marginal_cost"],
hovertemplate=(
"Carrier: %{fullData.name}<br>"
"Capacity block: %{width:.2f} GW<br>"
"Marginal cost: %{customdata:.1f} GBP/MWh<extra></extra>"
),
)
fig.add_vline(
x=float(served.mean()),
line_color="#D62728",
annotation_text=f"Mean load: {served.mean():.1f} GW",
annotation_position="top left",
)
fig.add_vline(
x=float(served.max()),
line_dash="dot",
line_color="#D62728",
annotation_text=f"Peak load: {served.max():.1f} GW",
annotation_position="top right",
)
fig.update_layout(
title=f"Wholesale Merit Order - {scenario}",
xaxis_title="Cumulative average available capacity (GW)",
yaxis_title="Marginal cost (GBP/MWh)",
barmode="overlay",
bargap=0,
legend=dict(orientation="h", y=-0.22, x=0),
yaxis=dict(range=[-120, 220], zeroline=True, zerolinewidth=1, zerolinecolor="#333333"),
)
return style_plot(fig, height=650)
plot_merit_order().show()
fig = go.Figure()
fig.add_scatter(
x=score["period"].astype(str),
y=score["smp_mid_ratio"],
mode="lines+markers+text",
text=score["smp_mid_ratio"].map(lambda x: f"{x:.2f}x"),
textposition="top center",
name="SMP / MID",
line=dict(color="#4C9F70", width=3),
)
fig.add_hline(y=1.0, line_dash="dash", line_color="#545b66", annotation_text="Parity with ELEXON MID")
fig.update_layout(title="Wholesale Price Ratio by Month")
fig.update_yaxes(title_text="Model SMP / ELEXON MID", range=[0, max(1.25, score["smp_mid_ratio"].max() * 1.15)])
style_plot(fig, height=430).show()
def plot_wholesale_price_trace():
model_price = wholesale_price_series.rename("Model SMP")
elexon_price = mid_prices.reindex(model_price.index).interpolate(limit_direction="both").rename("ELEXON MID")
comparison = pd.concat([model_price, elexon_price], axis=1).dropna().loc["2020-01-01":"2020-12-31 23:30"]
comparison["Delta"] = comparison["Model SMP"] - comparison["ELEXON MID"]
corr = comparison["Model SMP"].corr(comparison["ELEXON MID"])
mae = comparison["Delta"].abs().mean()
bias = comparison["Delta"].mean()
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, row_heights=[0.72, 0.28], vertical_spacing=0.08)
fig.add_scatter(row=1, col=1, x=comparison.index, y=comparison["ELEXON MID"], mode="lines", name=f"ELEXON MID mean: GBP {comparison['ELEXON MID'].mean():.1f}/MWh", line=dict(color="#F58518", width=1.5))
fig.add_scatter(row=1, col=1, x=comparison.index, y=comparison["Model SMP"], mode="lines", name=f"Model SMP mean: GBP {comparison['Model SMP'].mean():.1f}/MWh", line=dict(color="#1F4E79", width=1.8))
fig.add_scatter(row=2, col=1, x=comparison.index, y=comparison["Delta"].where(comparison["Delta"] >= 0), mode="lines", name="Model above ELEXON", line=dict(color="#4C78A8", width=0), fill="tozeroy", fillcolor="rgba(76,120,168,0.55)", connectgaps=False)
fig.add_scatter(row=2, col=1, x=comparison.index, y=comparison["Delta"].where(comparison["Delta"] < 0), mode="lines", name="Model below ELEXON", line=dict(color="#E45756", width=0), fill="tozeroy", fillcolor="rgba(228,87,86,0.55)", connectgaps=False)
fig.add_hline(row=2, col=1, y=0, line_color="#333333", line_width=1)
fig.add_annotation(row=1, col=1, text=f"r = {corr:.2f}<br>MAE = GBP {mae:.1f}/MWh<br>Bias = GBP {bias:+.1f}/MWh", xref="paper", yref="paper", x=0.99, y=0.94, showarrow=False, align="right", bgcolor="rgba(255,255,255,0.85)", bordercolor="#DDDDDD", borderwidth=1)
fig.update_layout(title="Wholesale Price: Model SMP vs ELEXON MID - 2020")
fig.update_yaxes(row=1, col=1, title_text="GBP/MWh")
fig.update_yaxes(row=2, col=1, title_text="Delta<br>GBP/MWh")
fig.update_xaxes(row=2, col=1, title_text="2020")
fig.update_xaxes(range=[pd.Timestamp("2020-01-01"), pd.Timestamp("2021-01-01")])
return style_plot(fig, height=650)
plot_wholesale_price_trace().show()
4. Balancing Mechanism Evidence#
All BM evidence is grouped here so the model redispatch, BOAV actions, and NESO thermal constraint volumes can be read together. Model redispatch volume is computed from the change between wholesale and final balancing dispatch.
BOAV is used for accepted bid/offer action volume and can be compared by carrier after mapping. NESO thermal constraint volume is system-level, so it is included in the monthly volume comparison but not allocated across carriers.
[4]:
monthly = score[["period", "model_one_sided_redispatch_mwh", "neso_thermal_volume_abs_mwh"]].merge(boav_monthly, on="period", how="left")
monthly_plot = monthly.rename(columns={
"model_one_sided_redispatch_mwh": "Model one-sided redispatch",
"increase_mwh": "BOAV increase",
"decrease_mwh": "BOAV decrease",
"neso_thermal_volume_abs_mwh": "NESO thermal constraint volume",
}).melt("period", var_name="series", value_name="MWh")
monthly_plot["GWh"] = monthly_plot["MWh"] / 1000
fig = px.bar(
monthly_plot,
x=monthly_plot["period"].astype(str),
y="GWh",
color="series",
barmode="group",
labels={"x": "", "GWh": "GWh", "series": ""},
title="BM Redispatch Volume vs BOAV and NESO Thermal Constraint Volume",
template=plot_template,
height=560,
)
fig.show()
carrier_order = ["CCGT", "coal", "pumped storage", "hydro_non_ps", "biomass", "wind", "OCGT", "nuclear", "unknown", "load_shedding"]
model_carrier = (
redispatch.groupby("carrier_compare", as_index=False)[["increase_mwh", "decrease_mwh"]]
.sum()
.rename(columns={"carrier_compare": "carrier", "increase_mwh": "model_increase_mwh", "decrease_mwh": "model_decrease_mwh"})
)
boav_carrier = (
boav_by_carrier.groupby("carrier_compare", as_index=False, observed=True)[["increase_mwh", "decrease_mwh"]]
.sum()
.rename(columns={"carrier_compare": "carrier", "increase_mwh": "boav_increase_mwh", "decrease_mwh": "boav_decrease_mwh"})
)
carrier_vol = model_carrier.merge(boav_carrier, on="carrier", how="outer").fillna(0.0)
carrier_vol["carrier"] = pd.Categorical(carrier_vol["carrier"], carrier_order, ordered=True)
carrier_vol = carrier_vol.sort_values("carrier")
for col in ["model_increase_mwh", "model_decrease_mwh", "boav_increase_mwh", "boav_decrease_mwh"]:
carrier_vol[col.replace("_mwh", "_gwh")] = carrier_vol[col] / 1000
fig = make_subplots(rows=1, cols=2, shared_yaxes=True, horizontal_spacing=0.1, subplot_titles=("Increase / turn-up", "Decrease / turn-down"))
fig.add_bar(row=1, col=1, y=carrier_vol["carrier"].astype(str), x=carrier_vol["model_increase_gwh"], orientation="h", name="Model", marker_color="#4C78A8")
fig.add_bar(row=1, col=1, y=carrier_vol["carrier"].astype(str), x=carrier_vol["boav_increase_gwh"], orientation="h", name="BOAV", marker_color="#F58518")
fig.add_bar(row=1, col=2, y=carrier_vol["carrier"].astype(str), x=carrier_vol["model_decrease_gwh"], orientation="h", name="Model ", marker_color="#4C78A8", showlegend=False)
fig.add_bar(row=1, col=2, y=carrier_vol["carrier"].astype(str), x=carrier_vol["boav_decrease_gwh"], orientation="h", name="BOAV ", marker_color="#F58518", showlegend=False)
fig.update_layout(
title=f"BM Volumes by Carrier: Model vs BOAV (NESO thermal volume is system-level: {score['neso_thermal_volume_abs_mwh'].sum() / 1000:,.0f} GWh)",
barmode="group",
)
fig.update_xaxes(title_text="Volume (GWh)", row=1, col=1)
fig.update_xaxes(title_text="Volume (GWh)", row=1, col=2)
fig.update_yaxes(autorange="reversed", row=1, col=1)
style_plot(fig, height=650).show()
fig = go.Figure()
fig.add_bar(x=score["period"].astype(str), y=score["bm_cost_gbp_m"], name="Model redispatch cost indicator", marker_color="#345995")
fig.add_bar(x=score["period"].astype(str), y=score["neso_thermal_cost_gbp_m"], name="NESO thermal constraint cost", marker_color="#E26D5A")
fig.update_layout(title="Monthly Redispatch Cost Indicator Compared With NESO Real Thermal Constraint Cost", barmode="group")
fig.update_yaxes(title_text="GBP million")
style_plot(fig, height=430).show()
5. Network Boundary Evidence#
This is the physical network check. Boundary flows are recomputed from the monthly balancing networks using the strict NESO boundary mapping used for the uniform-baseline validation case, with particular attention to SSE-SP.
The plots are intended to show both whether constraints bind and whether any periods exceed the published boundary limits. A limit exceedance is a review flag: it can indicate a genuine model issue, a boundary-mapping issue, or a difference between static validation limits and operational limit setting.
[5]:
def plot_neso_boundary_case_study(selected=("SCOTEX", "SSE-SP", "SSHARN::SSHARN_SPARSE_GATE_FIT")):
fig = make_subplots(rows=len(selected), cols=1, shared_xaxes=True, vertical_spacing=0.05, subplot_titles=[b.replace("::SSHARN_SPARSE_GATE_FIT", " sparse gate") for b in selected])
for row, boundary in enumerate(selected, start=1):
spec = boundary_mapping["boundaries"][boundary]
neso_boundary = spec.get("neso_boundary", boundary)
nb = neso_boundary_raw[neso_boundary_raw["Constraint Group"] == neso_boundary]
hourly = nb.groupby("hour")[["Limit (MW)", "Flow (MW)"]].mean()
model_flow = model_boundary_flows.get(boundary, pd.Series(dtype=float)).reindex(hourly.index).interpolate(limit_direction="both")
limit_gw = (hourly["Limit (MW)"] / 1000.0).where(lambda s: s < 20.0)
fig.add_scatter(row=row, col=1, x=hourly.index, y=limit_gw, mode="lines", name="NESO DA limit", line=dict(color="#9EC5F8", width=0), fill="tozeroy", fillcolor="rgba(158,197,248,0.45)", showlegend=row == 1)
fig.add_scatter(row=row, col=1, x=hourly.index, y=hourly["Flow (MW)"] / 1000.0, mode="lines", name="NESO DA flow", line=dict(color="#F58518", width=1.4), showlegend=row == 1)
fig.add_scatter(row=row, col=1, x=model_flow.index, y=model_flow / 1000.0, mode="lines", name="Model BM flow", line=dict(color="#1F4E79", width=1.8), showlegend=row == 1)
constrained = hourly["Limit (MW)"] < 50000
util = (hourly.loc[constrained, "Flow (MW)"] / hourly.loc[constrained, "Limit (MW)"]).replace([np.inf, -np.inf], np.nan)
model_util = (model_flow.loc[hourly.index[constrained]] / hourly.loc[constrained, "Limit (MW)"]).replace([np.inf, -np.inf], np.nan)
fig.add_annotation(row=row, col=1, text=f"NESO >=90%: {(util >= 0.9).mean() * 100:.0f}%<br>Model exceed hrs: {(model_util > 1.0).sum():.0f}", xref=f"x{row if row > 1 else ''} domain", yref=f"y{row if row > 1 else ''} domain", x=0.99, y=0.88, showarrow=False, align="right", font=dict(size=10, color="#333333"))
fig.update_yaxes(row=row, col=1, title_text=f"{boundary.split('::')[0]}<br>GW")
fig.update_layout(title="NESO Real Boundary Limits and Flows vs Model Boundary Flows - 2020")
fig.update_xaxes(row=len(selected), col=1, title_text="2020")
fig.update_xaxes(range=[pd.Timestamp("2020-01-01"), pd.Timestamp("2021-01-01")])
return style_plot(fig, height=760)
plot_neso_boundary_case_study().show()
top_boundaries = boundaries.sort_values("model_boundary_hours_above_90", ascending=False).head(18).copy()
top_boundaries["label"] = top_boundaries["period"].astype(str) + " - " + top_boundaries["boundary"].str.replace("::SSHARN_SPARSE_GATE_FIT", " sparse", regex=False)
fig = px.bar(
top_boundaries.sort_values("model_boundary_hours_above_90"),
x="model_boundary_hours_above_90",
y="label",
color="model_boundary_limit_exceed_hours",
orientation="h",
color_continuous_scale="Reds",
labels={"model_boundary_hours_above_90": "Model hours above 90% of NESO limit", "label": "", "model_boundary_limit_exceed_hours": "Exceed hours"},
title="Highest Model Boundary Utilisation Cases Derived From Balancing Networks",
template=plot_template,
height=560,
)
fig.show()
6. ESPENI Dispatch Comparison#
ESPENI is used as an independent observed physical-dispatch benchmark after the market and boundary checks. This gives a final view of whether the solved balancing dispatch produces credible annual technology-level output.
Wind and solar include ESPENI embedded generation, so this is a dispatch comparison, not an availability calibration target. Large differences should be read alongside the renewable capacity, embedded-generation treatment, and curtailment evidence in the earlier sections.
[6]:
focus = ["CCGT", "coal", "nuclear", "biomass", "hydro_non_ps", "wind", "solar", "pumped storage", "model_demand_profile"]
heat = (
dispatch_compare[dispatch_compare["comparison"].isin(focus)]
.pivot_table(index="comparison", columns="period", values="model_espeni_ratio", aggfunc="sum", observed=True)
.reindex(focus)
)
heat = heat[MONTH_ORDER]
fig = px.imshow(
heat,
color_continuous_scale="RdBu",
zmin=0,
zmax=2,
aspect="auto",
labels={"x": "", "y": "", "color": "Model / ESPENI"},
title="Model Final Physical Dispatch vs ESPENI",
template=plot_template,
height=520,
)
fig.update_traces(text=np.round(heat.values, 2), texttemplate="%{text}")
fig.show()
annual_espeni = (
dispatch_compare[dispatch_compare["comparison"].isin(focus)]
.groupby("comparison", as_index=False)[["model_mwh", "espeni_mwh"]]
.sum()
)
annual_espeni["model_espeni_ratio"] = annual_espeni["model_mwh"] / annual_espeni["espeni_mwh"].replace(0, np.nan)
display(annual_espeni.set_index("comparison").reindex(focus).round(2))
| model_mwh | espeni_mwh | model_espeni_ratio | |
|---|---|---|---|
| comparison | |||
| CCGT | 88,403,686.200 | 94,990,067.500 | 0.930 |
| coal | 226,359.000 | 4,381,873.500 | 0.050 |
| nuclear | 47,138,094.630 | 47,407,780.000 | 0.990 |
| biomass | 19,669,898.210 | 17,997,149.500 | 1.090 |
| hydro_non_ps | 2,368,261.240 | 4,314,369.500 | 0.550 |
| wind | 76,562,155.890 | 72,943,684.000 | 1.050 |
| solar | 12,335,410.050 | 12,062,276.000 | 1.020 |
| pumped storage | 474,501.240 | 1,493,022.000 | 0.320 |
| model_demand_profile | 280,043,421.000 | 275,586,455.500 | 1.020 |
7. Appendix Tables#
The appendix keeps the monthly audit table visible for reviewers who want to trace the plotted KPIs back to the underlying month-level values.
[7]:
display_cols = [
"period", "bm_cost_gbp_m", "neso_thermal_cost_gbp_m", "bm_vs_neso_thermal_ratio",
"model_one_sided_redispatch_mwh", "neso_thermal_volume_abs_mwh",
"load_shedding_mwh", "mean_smp_gbp_mwh", "mean_mid_gbp_mwh", "smp_mid_ratio",
]
table = score[display_cols].rename(columns={
"period": "Month",
"bm_cost_gbp_m": "Model redispatch cost indicator (GBPm)",
"neso_thermal_cost_gbp_m": "NESO thermal cost (GBPm)",
"bm_vs_neso_thermal_ratio": "Cost indicator / NESO cost",
"model_one_sided_redispatch_mwh": "Model one-sided redispatch (MWh)",
"neso_thermal_volume_abs_mwh": "NESO thermal volume (MWh)",
"load_shedding_mwh": "Load shedding (MWh)",
"mean_smp_gbp_mwh": "Model SMP (GBP/MWh)",
"mean_mid_gbp_mwh": "ELEXON MID (GBP/MWh)",
"smp_mid_ratio": "SMP / MID",
})
display(table.round(2))
display(boundaries[[
"period", "boundary", "model_max_flow_mw", "neso_mean_limit_mw",
"model_boundary_hours_above_90", "model_boundary_limit_exceed_hours",
"model_boundary_max_limit_exceed_mw",
]].round(2))
| Month | Model redispatch cost indicator (GBPm) | NESO thermal cost (GBPm) | Cost indicator / NESO cost | Model one-sided redispatch (MWh) | NESO thermal volume (MWh) | Load shedding (MWh) | Model SMP (GBP/MWh) | ELEXON MID (GBP/MWh) | SMP / MID | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Jan 2020 | 71.530 | 72.600 | 0.990 | 1,040,076.870 | 736,380.000 | 0.010 | 36.530 | 34.940 | 1.050 |
| 1 | Feb 2020 | 58.540 | 76.150 | 0.770 | 844,840.450 | 779,093.000 | 0.000 | 34.040 | 29.490 | 1.150 |
| 2 | Mar 2020 | 45.630 | 40.510 | 1.130 | 633,025.290 | 460,646.000 | 0.000 | 29.400 | 28.490 | 1.030 |
| 3 | Apr 2020 | 18.600 | 10.700 | 1.740 | 234,057.150 | 122,679.000 | 0.000 | 24.220 | 23.050 | 1.050 |
| 4 | May 2020 | 22.160 | 34.830 | 0.640 | 277,844.880 | 327,806.000 | 0.000 | 21.270 | 22.030 | 0.970 |
| 5 | Jun 2020 | 29.640 | 33.380 | 0.890 | 385,869.270 | 473,286.000 | 0.010 | 28.340 | 25.520 | 1.110 |
| 6 | Jul 2020 | 29.740 | 40.340 | 0.740 | 380,802.050 | 452,897.000 | 0.000 | 28.250 | 27.670 | 1.020 |
| 7 | Aug 2020 | 21.210 | 17.390 | 1.220 | 268,024.690 | 178,959.000 | 0.000 | 33.050 | 35.230 | 0.940 |
| 8 | Sep 2020 | 12.220 | 40.080 | 0.300 | 153,472.200 | 319,840.000 | 0.000 | 38.610 | 43.160 | 0.890 |
| 9 | Oct 2020 | 22.670 | 54.220 | 0.420 | 288,996.150 | 452,850.000 | 0.000 | 39.800 | 41.470 | 0.960 |
| 10 | Nov 2020 | 41.520 | 111.080 | 0.370 | 556,707.240 | 886,526.000 | 0.010 | 36.350 | 36.120 | 1.010 |
| 11 | Dec 2020 | 34.270 | 64.770 | 0.530 | 452,166.550 | 417,607.000 | 0.010 | 46.720 | 54.050 | 0.860 |
| period | boundary | model_max_flow_mw | neso_mean_limit_mw | model_boundary_hours_above_90 | model_boundary_limit_exceed_hours | model_boundary_max_limit_exceed_mw | |
|---|---|---|---|---|---|---|---|
| 0 | Jan 2020 | ESTEX | 7,033.870 | 8,488.000 | 11 | 0 | 0.000 |
| 1 | Jan 2020 | SCOTEX | 4,636.080 | 4,441.370 | 208 | 0 | 0.000 |
| 2 | Jan 2020 | SEIMP | 7,141.510 | 7,427.860 | 3 | 0 | 0.000 |
| 3 | Jan 2020 | SSE-SP | 3,036.900 | 2,557.430 | 60 | 0 | 0.000 |
| 4 | Jan 2020 | SSHARN::SSHARN_SPARSE_GATE_FIT | 8,750.000 | 7,433.990 | 306 | 4 | 425.000 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 67 | Dec 2020 | SCOTEX | 4,642.380 | 5,425.000 | 8 | 0 | 0.000 |
| 68 | Dec 2020 | SEIMP | 7,081.180 | 6,021.670 | 136 | 23 | 2,281.180 |
| 69 | Dec 2020 | SSE-SP | 2,468.710 | 2,333.330 | 12 | 0 | 0.000 |
| 70 | Dec 2020 | SSHARN::SSHARN_SPARSE_GATE_FIT | 7,450.680 | 7,178.660 | 112 | 0 | 0.000 |
| 71 | Dec 2020 | SWALEX | 2,263.120 | NaN | 0 | 0 | 0.000 |
72 rows × 7 columns