viewof fromYear = Inputs.range([firstYear, lastFull], {value: lastFull - 4, step: 1, label: "From the start of"})
viewof toYear = Inputs.range([firstYear, lastFull], {value: lastFull, step: 1, label: "To the end of"})
viewof basis = Inputs.radio(["Average", "Worst year", "Best year"], {value: "Average", label: "Load factor to use for renewables"})Without the hype
Nuclear vs Renewables: Output per GW
Energy
An interactive dashboard comparing what each GW of the UK’s nuclear fleet, offshore wind, onshore wind and solar actually generates, how much it varies, and what Hinkley Point C and Sizewell C would add at their higher capacity factors.
Published
6 November 2026
D = FileAttachment("nvr.json").json()
fmt = new Intl.NumberFormat("en-GB", {maximumFractionDigits: 0})
f1 = x => x.toFixed(1)
pc = x => `${Math.round(100 * x)}%`
HOURS = 8.766 // thousand hours in an average year: TWh per GW at 100%
firstYear = D.annual[0].year
lastFull = D.last_full
latest = new Date(D.latest)
latestLong = latest.toLocaleDateString("en-GB", {day: "numeric", month: "long", year: "numeric", timeZone: "UTC"})
// one colour per technology, in a fixed order (validated as a set)
TECH = [
{key: "nuclear", name: "Nuclear", colour: "#b23a48"},
{key: "offshore", name: "Offshore wind", colour: "#2f6aa3"},
{key: "onshore", name: "Onshore wind", colour: "#2a9d78"},
{key: "solar", name: "Solar PV", colour: "#d9a21b"},
{key: "biomass", name: "Plant biomass", colour: "#d0663a"},
{key: "hydro", name: "Hydro", colour: "#8a62b8"}
]
techName = Object.fromEntries(TECH.map(t => [t.key, t.name]))
techColour = Object.fromEntries(TECH.map(t => [t.key, t.colour]))
HPC_GW = 3.26
SZC_GW = 3.26
fleetGw = d3.sum(D.fleet, s => s.mw) / 1e3
agrGw = d3.sum(D.fleet.filter(s => s.name !== "Sizewell B"), s => s.mw) / 1e3
szbGw = fleetGw - agrGwThe UK’s five nuclear stations, GW in all, against its wind farms, solar panels, hydro and biomass: how much electricity each GW of capacity has actually produced each year since , how steady that output is, and what Hinkley Point C and Sizewell C would add if they reach the higher capacity factors their EPR reactors are designed for. Annual figures are DESNZ’s; the half-hourly and daily variability comes from NESO’s generation data to . Change the years and assumptions below and everything follows.
Years to average
html`<p class="cfd-note">Load factors averaged over ${span[0]}–${span[1]}${span[0] === span[1] ? "" : `, ${span[1] - span[0] + 1} years`}. "Worst" and "best" take each technology's lowest or highest year in that range, to show how much a still or windy year changes the answer.${toYear < fromYear ? " The end year is before the start year, so the range is swapped." : ""}</p>`New nuclear
viewof eprCf = Inputs.range([60, 95], {value: 90, step: 1, label: "Hinkley C and Sizewell C capacity factor (%)"})
viewof hpcStart = Inputs.range([2029, 2036], {value: 2030, step: 1, label: "Hinkley Point C unit 1 starts"})
viewof szcStart = Inputs.range([2034, 2045], {value: 2039, step: 1, label: "Sizewell C unit 1 starts"})Today’s fleet
viewof fleetCfIn = Inputs.range([40, 90], {value: Math.round(avgNuclearLf), step: 1, label: "Existing fleet capacity factor (%)"})
viewof agrEnd = Inputs.range([2027, 2035], {value: 2030, step: 1, label: "AGRs close at the end of March"})
viewof szbEnd = Inputs.range([2035, 2055], {value: 2055, step: 1, label: "Sizewell B closes at the end of"})span = [Math.min(fromYear, toYear), Math.max(fromYear, toYear)]
yrs = D.annual.filter(r => r.year >= span[0] && r.year <= span[1])
lfStats = Object.fromEntries(TECH.map(t => {
const v = yrs.map(r => r[t.key].lf);
return [t.key, {mean: d3.mean(v), min: d3.min(v), max: d3.max(v), sd: v.length > 1 ? d3.deviation(v) : 0}];
}))
avgNuclearLf = lfStats.nuclear.mean
pick = k => basis === "Average" ? lfStats[k].mean : basis === "Worst year" ? lfStats[k].min : lfStats[k].max
newTwh = (HPC_GW + SZC_GW) * eprCf / 100 * HOURS
hpcTwh = HPC_GW * eprCf / 100 * HOURS
fleetTwh = fleetGw * fleetCfIn / 100 * HOURS
RENEW = ["offshore", "onshore", "solar"]
gwToMatch = (twh, k) => twh / (pick(k) / 100 * HOURS)html`<div class="stat-row">
<div class="stat"><span class="stat-value">${f1(lfStats.nuclear.mean / 100 * HOURS)} TWh</span><span class="stat-label">a year from each GW of today's nuclear fleet, ${span[0]}–${span[1]} (${f1(lfStats.nuclear.mean)}% load factor)</span></div>
<div class="stat"><span class="stat-value">${f1(eprCf / 100 * HOURS)} TWh</span><span class="stat-label">a year per GW at Hinkley Point C and Sizewell C, at ${eprCf}%</span></div>
<div class="stat"><span class="stat-value">${f1(lfStats.offshore.mean / 100 * HOURS)} TWh</span><span class="stat-label">per GW of offshore wind (${f1(lfStats.offshore.mean)}%)</span></div>
<div class="stat"><span class="stat-value">${f1(lfStats.onshore.mean / 100 * HOURS)} TWh</span><span class="stat-label">per GW of onshore wind (${f1(lfStats.onshore.mean)}%)</span></div>
<div class="stat"><span class="stat-value">${f1(lfStats.solar.mean / 100 * HOURS)} TWh</span><span class="stat-label">per GW of solar PV (${f1(lfStats.solar.mean)}%)</span></div>
</div>`Generation per GW installed
html`<p class="chart-sub">Electricity generated each year for each GW of capacity installed (TWh per GW, the load factor × 8,766 hours), ${firstYear}–${lastFull}. The dashed line is the ${eprCf}% assumed for Hinkley Point C and Sizewell C; the shaded band is the years being averaged.</p>`{
const keys = ["nuclear", "offshore", "onshore", "solar", "biomass", "hydro"];
const rows = D.annual.flatMap(r => keys.map(k => ({year: r.year, key: k, name: techName[k], v: r[k].lf / 100 * HOURS, lf: r[k].lf})));
const ends = keys.map(k => rows.filter(r => r.key === k).at(-1));
// nudge end labels apart
ends.sort((a, b) => b.v - a.v).forEach((d, i) => { d.ly = i && ends[i - 1].ly - d.v < 0.32 ? ends[i - 1].ly - 0.32 : d.v; });
return Plot.plot({
className: "cfd-plot", width, height: 400, marginLeft: 44, marginRight: width < 600 ? 16 : 110,
x: {label: null, tickFormat: d => String(d), domain: [firstYear, lastFull]},
y: {label: "TWh per GW per year", grid: true, domain: [0, 8.766]},
color: {domain: keys.map(k => techName[k]), range: keys.map(k => techColour[k]), legend: true},
marks: [
Plot.rectX([span], {x1: d => d[0] - 0.5, x2: d => d[1] + 0.5, fill: "var(--cfd-context)", fillOpacity: 0.25}),
Plot.ruleY([eprCf / 100 * HOURS], {stroke: techColour.nuclear, strokeDasharray: "5,4", strokeWidth: 1.5}),
Plot.text([eprCf / 100 * HOURS], {x: firstYear, y: d => d, text: () => `Hinkley C / Sizewell C at ${eprCf}%`, textAnchor: "start", dy: -8, fill: "currentColor", stroke: "var(--cfd-bg)", strokeWidth: 3}),
Plot.line(rows, {x: "year", y: "v", stroke: "name", strokeWidth: 2}),
Plot.dot(rows, {x: "year", y: "v", fill: "name", r: 2.5}),
width < 600 ? null : Plot.text(ends, {x: "year", y: "ly", text: "name", textAnchor: "start", dx: 8, fill: "currentColor"}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(rows, Plot.pointer({x: "year", y: "v", title: d => `${d.year} · ${d.name}\n${f1(d.v)} TWh per GW (${f1(d.lf)}% load factor)`}))
]
});
}What it takes to match Hinkley Point C and Sizewell C
html`<p>At ${eprCf}%, Hinkley Point C and Sizewell C together (${f1(HPC_GW + SZC_GW)} GW) would generate about <strong>${fmt.format(newTwh)} TWh a year</strong>, against ${fmt.format(fleetTwh)} TWh from today's ${f1(fleetGw)} GW fleet at ${fleetCfIn}%. Matching that energy over a year would take
${RENEW.map(k => `<strong>${fmt.format(gwToMatch(newTwh, k))} GW</strong> of ${techName[k].replace(/^[A-Z][a-z]/, c => c.toLowerCase())}`).join(", ").replace(/, ([^,]*)$/, " or $1")}
at ${basis === "Average" ? `the average load factor of ${span[0]}–${span[1]}` : `the load factor of the ${basis === "Worst year" ? "worst" : "best"} year in ${span[0]}–${span[1]}`}. That is energy over a year only: it says nothing about when the power comes, which the sections below look at.</p>`html`<p class="chart-sub">GW of each technology needed to generate the same energy in a year as Hinkley Point C and Sizewell C at ${eprCf}%. The bar uses the ${basis.toLowerCase()} load factor; the whisker runs from the best year (fewest GW) to the worst (most) in ${span[0]}–${span[1]}. Installed today, for scale: ${f1(D.renew_cap_latest.wind)} GW of wind and ${f1(D.renew_cap_latest.solar)} GW of solar.</p>`{
const keys = ["nuclear", "offshore", "onshore", "solar"];
const rows = keys.map(k => {
const cf = k === "nuclear" ? null : lfStats[k];
const need = lf => newTwh / (lf / 100 * HOURS);
return k === "nuclear"
? {key: k, name: "Hinkley C + Sizewell C", gw: HPC_GW + SZC_GW, lo: HPC_GW + SZC_GW, hi: HPC_GW + SZC_GW, lf: eprCf}
: {key: k, name: techName[k], gw: need(pick(k)), lo: need(cf.max), hi: need(cf.min), lf: pick(k)};
});
return Plot.plot({
className: "cfd-plot", width, height: 230, marginLeft: width < 600 ? 120 : 160, marginRight: 60,
x: {label: "GW of capacity", grid: true},
y: {label: null, domain: rows.map(r => r.name)},
marks: [
Plot.barX(rows, {y: "name", x: "gw", fill: d => techColour[d.key], rx: 3, insetTop: 6, insetBottom: 6,
tip: true, title: d => `${d.name}\n${f1(d.gw)} GW at ${f1(d.lf)}%${d.key === "nuclear" ? "" : `\nRange ${f1(d.lo)}–${f1(d.hi)} GW`}`}),
Plot.ruleY(rows.filter(r => r.key !== "nuclear"), {y: "name", x1: "lo", x2: "hi", stroke: "currentColor", strokeWidth: 1.5}),
Plot.tickX(rows.filter(r => r.key !== "nuclear"), {y: "name", x: "lo", stroke: "currentColor", inset: 10}),
Plot.tickX(rows.filter(r => r.key !== "nuclear"), {y: "name", x: "hi", stroke: "currentColor", inset: 10}),
Plot.text(rows, {y: "name", x: d => Math.max(d.gw, d.hi), text: d => `${f1(d.gw)} GW`, textAnchor: "start", dx: 6, fill: "currentColor"}),
Plot.ruleX([0], {stroke: "currentColor", strokeOpacity: 0.5})
]
});
}How sensitive is that to the capacity factors?
html`<p class="chart-sub">GW of offshore wind needed to match Hinkley Point C and Sizewell C, for each new-nuclear capacity factor (across) and offshore wind load factor (lines). The dot is the current choice.</p>`{
const lfs = [30, 35, 40, 45, 50].map(v => ({v}));
const ramp = d3.interpolateRgb("#9cc3e4", "#14365c");
const rows = lfs.flatMap((l, i) => d3.range(60, 96, 1).map(c => ({cf: c, lf: l.v, gw: (HPC_GW + SZC_GW) * c / l.v, i})));
const now = {cf: eprCf, lf: pick("offshore"), gw: (HPC_GW + SZC_GW) * eprCf / pick("offshore")};
const ends = lfs.map((l, i) => rows.filter(r => r.lf === l.v).at(-1));
return Plot.plot({
className: "cfd-plot", width, height: 320, marginLeft: 44, marginRight: 90,
x: {label: "Hinkley C and Sizewell C capacity factor (%)", domain: [60, 95]},
y: {label: "GW of offshore wind", grid: true, domain: [0, 22]},
marks: [
Plot.line(rows, {x: "cf", y: "gw", z: "lf", stroke: d => ramp(d.i / 4), strokeWidth: 2}),
Plot.text(ends, {x: "cf", y: "gw", text: d => `${d.lf}% wind`, textAnchor: "start", dx: 6, fill: "currentColor"}),
Plot.dot([now], {x: "cf", y: "gw", r: 6, fill: techColour.offshore, stroke: "var(--cfd-bg)", strokeWidth: 2}),
Plot.text([now], {x: "cf", y: "gw", text: d => `${f1(d.gw)} GW at ${f1(d.lf)}%`, dy: -14, fill: "currentColor", stroke: "var(--cfd-bg)", strokeWidth: 3}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(rows, Plot.pointer({x: "cf", y: "gw", title: d => `Nuclear ${d.cf}%, offshore wind ${d.lf}%\n${f1(d.gw)} GW of offshore wind`}))
]
});
}Nuclear output, past and projected
html`<p class="chart-sub">UK nuclear generation each year (TWh): DESNZ's figures to ${lastFull}, then the projection from your assumptions, by station group. For comparison, the lines show what today's installed wind (${f1(D.renew_cap_latest.wind)} GW) and solar (${f1(D.renew_cap_latest.solar)} GW) generate at the ${basis.toLowerCase()} load factor of ${span[0]}–${span[1]}.</p>`proj = {
const unit = (start, y) => y < start ? 0 : y === start ? 0.5 : 1;
const out = [];
for (let y = lastFull + 1; y <= 2045; y++) {
const agrShare = y < agrEnd ? 1 : y === agrEnd ? 0.25 : 0;
const szb = y <= szbEnd ? 1 : 0;
out.push({year: y, group: "AGRs (Heysham, Hartlepool, Torness)", twh: agrGw * agrShare * fleetCfIn / 100 * HOURS});
out.push({year: y, group: "Sizewell B", twh: szbGw * szb * fleetCfIn / 100 * HOURS});
out.push({year: y, group: "Hinkley Point C", twh: HPC_GW / 2 * (unit(hpcStart, y) + unit(hpcStart + 1, y)) * eprCf / 100 * HOURS});
out.push({year: y, group: "Sizewell C", twh: SZC_GW / 2 * (unit(szcStart, y) + unit(szcStart + 1, y)) * eprCf / 100 * HOURS});
}
return out;
}
GROUPS = ["Historic fleet", "AGRs (Heysham, Hartlepool, Torness)", "Sizewell B", "Hinkley Point C", "Sizewell C"]
GROUP_COL = ["#c4c6c0", "#e8a3ab", "#b23a48", "#6e1f2b", "#2a0c12"]{
const hist = D.annual.map(r => ({year: r.year, group: "Historic fleet", twh: r.nuclear.twh}));
const rows = [...hist, ...proj];
const totals = d3.rollups(rows, v => d3.sum(v, r => r.twh), r => r.year).map(([year, twh]) => ({year, twh}));
const windNow = D.renew_cap_latest.wind * (pick("offshore") * 0.5 + pick("onshore") * 0.5) / 100 * HOURS;
const solarNow = D.renew_cap_latest.solar * pick("solar") / 100 * HOURS;
return Plot.plot({
className: "cfd-plot", width, height: 400, marginLeft: 44, marginRight: width < 600 ? 16 : 120,
x: {label: null, tickFormat: d => String(d)},
y: {label: "TWh a year", grid: true},
color: {domain: GROUPS, range: GROUP_COL, legend: true},
marks: [
Plot.rectY(rows, {x: "year", interval: 1, y: "twh", fill: "group", order: GROUPS, insetLeft: 1, insetRight: 1}),
Plot.ruleX([lastFull + 1], {stroke: "currentColor", strokeOpacity: 0.4, strokeDasharray: "2,3"}),
Plot.ruleY([windNow], {stroke: techColour.offshore, strokeWidth: 1.5, strokeDasharray: "5,4"}),
Plot.ruleY([solarNow], {stroke: techColour.solar, strokeWidth: 1.5, strokeDasharray: "5,4"}),
width < 600 ? null : Plot.text([[windNow, "Today's wind"], [solarNow, "Today's solar"]], {x: 2046, y: d => d[0], text: d => d[1], textAnchor: "start", dx: 4, fill: "currentColor"}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.5}),
Plot.tip(totals, Plot.pointerX({x: d => d.year + 0.5, y: "twh", title: d => `${d.year}${d.year > lastFull ? " (projected)" : ""}\n${fmt.format(d.twh)} TWh of nuclear\n` +
rows.filter(r => r.year === d.year && r.twh > 0.05).map(r => `${r.group}: ${f1(r.twh)} TWh`).join("\n")}))
]
});
}html`<p class="cfd-note">Today's wind uses the average of the offshore and onshore load factors, as the installed GW are roughly half each. The projection assumes each station runs at the chosen capacity factor every year; in practice the AGRs' output has fallen as they age, with long outages for graphite inspections.</p>`How steady is the output?
The annual figures hide most of the difference. A GW of nuclear runs at much the same output day after day, broken by refuelling and outages; a GW of wind swings from almost nothing to most of its capacity within days, and solar follows the sun and the seasons. The charts cover the GB fleet only. Wind is split into offshore and onshore using Elexon’s figures (see Wind in detail), for the years it can be used; other years show wind as one.
dailyDates = D.daily.nuclear.map((_, i) => new Date(Date.parse(D.daily.start) + i * 864e5))
yearsAvail = [...new Set(dailyDates.map(d => d.getUTCFullYear()))]
splitYears = D.wind.years
hasSplit = y => splitYears.includes(y)
V_NUC = ({key: "nuclear", name: "Nuclear", colour: techColour.nuclear})
V_SOL = ({key: "solar", name: "Solar PV", colour: techColour.solar})
VAR_SPLIT = [V_NUC, {key: "offshore", name: "Offshore wind", colour: techColour.offshore},
{key: "onshore", name: "Onshore wind", colour: techColour.onshore}, V_SOL]
VAR_ONE = [V_NUC, {key: "wind", name: "Wind (onshore and offshore)", colour: "var(--cfd-ink)"}, V_SOL]viewof varYear = Inputs.select(yearsAvail.slice().reverse(), {value: lastFull, label: "Year to look at",
format: y => `${y}${y === latest.getUTCFullYear() ? ` (to ${latest.toLocaleDateString("en-GB", {day: "numeric", month: "short", timeZone: "UTC"})})` : ""}${hasSplit(y) ? "" : " · wind not split"}`})VAR = hasSplit(varYear) ? VAR_SPLIT : VAR_ONE
// the years the monthly chart averages wind over: the split years inside the chosen range, or all of them if none are
monthWindYears = splitYears.some(y => y >= span[0] && y <= span[1]) ? splitYears.filter(y => y >= span[0] && y <= span[1]) : splitYears
dayRows = dailyDates.map((date, i) => ({date, i})).filter(d => d.date.getUTCFullYear() === varYear)
.flatMap(d => VAR.map(t => ({date: d.date, key: t.key, name: t.name, cf: D.daily[t.key][d.i]}))).filter(d => d.cf != null)
// longest run of consecutive days below a threshold
longestRun = (vals, th) => { let best = 0, run = 0; for (const v of vals) { run = v < th ? run + 1 : 0; best = Math.max(best, run); } return best; }
varStats = VAR.map(t => {
const dv = dayRows.filter(d => d.key === t.key).map(d => d.cf);
const dur = D.duration[t.key][varYear];
return {...t, n: dv.length, mean: d3.mean(dv), dmin: d3.min(dv), dmax: d3.max(dv),
p90: dur ? dur[90] : NaN, p50: dur ? dur[50] : NaN, p10: dur ? dur[10] : NaN, hhMin: dur ? dur[100] : NaN,
below10: dv.filter(v => v < 0.1).length, run10: longestRun(dv, 0.1),
lfSd: t.key === "wind" ? d3.deviation(yrs.map(r => r.wind_lf)) ?? 0 : lfStats[t.key].sd};
})html`<div class="nvr-wrap"><table class="nvr-table">
<thead><tr><th>${varYear}</th><th>Average</th><th>90% of the time above</th><th>Median</th><th>10% of the time above</th><th>Lowest day</th><th>Highest day</th><th>Days below 10%</th><th>Longest spell below 10%</th><th>Spread of annual load factor, ${span[0]}–${span[1]}</th></tr></thead>
<tbody>${varStats.map(s => html`<tr><td><span class="sw" style="background:${s.colour}"></span>${s.name}</td>
<td>${pc(s.mean)}</td><td>${pc(s.p90)}</td><td>${pc(s.p50)}</td><td>${pc(s.p10)}</td><td>${pc(s.dmin)}</td><td>${pc(s.dmax)}</td>
<td>${s.below10}</td><td>${s.run10} day${s.run10 === 1 ? "" : "s"}</td><td>±${f1(s.lfSd)} pts</td></tr>`)}</tbody>
</table></div>`html`<p class="cfd-note">Percentages are output as a share of installed capacity. The 90%, median and 10% columns are from the half-hourly data: "90% of the time above" is the output you could count on in nine half-hours out of ten. The day columns use daily averages. Year-to-year spread is the standard deviation of DESNZ's annual load factors over the chosen years.</p>`Daily output through the year
html`<p class="chart-sub">Each day's average output as a share of installed capacity, ${varYear}.${hasSplit(varYear) ? "" : ` Elexon's onshore/offshore split isn't reliable for ${varYear}, so wind is shown as one.`}</p>`Plot.plot({
className: "cfd-plot", width, height: 340, marginLeft: 44, marginRight: 16,
x: {type: "utc", label: null},
y: {label: "Output, % of capacity", grid: true, domain: [0, 1], tickFormat: v => Math.round(v * 100)},
color: {domain: VAR.map(t => t.name), range: VAR.map(t => t.colour), legend: true},
marks: [
Plot.line(dayRows, {x: "date", y: "cf", stroke: "name", strokeWidth: 1.5}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(dayRows, Plot.pointer({x: "date", y: "cf", stroke: "name",
title: d => `${d.date.toLocaleDateString("en-GB", {day: "numeric", month: "short", year: "numeric", timeZone: "UTC"})}\n${d.name}: ${pc(d.cf)}`}))
]
})Duration curve
html`<p class="chart-sub">Every half-hour of ${varYear} sorted from highest output to lowest, as a share of installed capacity. A flat line is steady output; the average height of each line is its load factor. The dashed line is Hinkley Point C and Sizewell C at ${eprCf}%, as if they ran flat out between outages.</p>`{
const rows = VAR.flatMap(t => (D.duration[t.key][varYear] ?? []).map((v, p) => ({p, v, name: t.name})));
return Plot.plot({
className: "cfd-plot", width, height: 340, marginLeft: 44, marginRight: 16,
x: {label: "Share of the year's half-hours (%)", domain: [0, 100]},
y: {label: "Output, % of capacity", grid: true, domain: [0, 1], tickFormat: v => Math.round(v * 100)},
color: {domain: VAR.map(t => t.name), range: VAR.map(t => t.colour), legend: true},
marks: [
Plot.ruleY([eprCf / 100], {stroke: techColour.nuclear, strokeDasharray: "5,4", strokeWidth: 1.5}),
Plot.line(rows, {x: "p", y: "v", stroke: "name", strokeWidth: 2}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(rows, Plot.pointerX({x: "p", y: "v", stroke: "name", title: d => `${d.name}\nAt least ${pc(d.v)} of capacity in ${d.p}% of half-hours`}))
]
});
}Seasons and time of day
html`<p class="chart-sub">Average output by month (% of capacity): nuclear and solar across ${span[0]}–${span[1]}, offshore and onshore wind across ${monthWindYears.join(", ")}${monthWindYears.some(y => y < span[0] || y > span[1]) ? " (the years the split is available)" : ""}. Wind is strongest in winter, when demand is highest; solar the reverse. Nuclear's month-to-month changes come from refuelling and outages, not the weather.</p>`{
const rows = [];
dailyDates.forEach((d, i) => {
const y = d.getUTCFullYear();
for (const t of VAR_SPLIT) {
const inYears = t.key === "offshore" || t.key === "onshore" ? monthWindYears.includes(y) : y >= span[0] && y <= span[1];
const v = D.daily[t.key][i];
if (inYears && v != null) rows.push({m: d.getUTCMonth(), name: t.name, v});
}
});
const agg = d3.flatRollup(rows, v => d3.mean(v, r => r.v), r => r.name, r => r.m).map(([name, m, v]) => ({name, m, v}));
const mon = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return Plot.plot({
className: "cfd-plot", width, height: 300, marginLeft: 44, marginRight: 16,
x: {label: null, domain: d3.range(12), tickFormat: m => mon[m]},
y: {label: "Output, % of capacity", grid: true, domain: [0, 0.8], tickFormat: v => Math.round(v * 100)},
color: {domain: VAR_SPLIT.map(t => t.name), range: VAR_SPLIT.map(t => t.colour), legend: true},
marks: [
Plot.line(agg, {x: "m", y: "v", stroke: "name", strokeWidth: 2}),
Plot.dot(agg, {x: "m", y: "v", fill: "name", r: 3.5}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(agg, Plot.pointer({x: "m", y: "v", stroke: "name", title: d => `${mon[d.m]} · ${d.name}\n${pc(d.v)} of capacity on average`}))
]
});
}html`<p class="chart-sub">Average output through the day (UTC hour): nuclear and solar 2021 to ${latest.getUTCFullYear()}, offshore and onshore wind across ${splitYears.join(", ")}.</p>`{
const rows = VAR_SPLIT.flatMap(t => D.profile[t.key][profMonth].map((v, h) => ({h, v, name: t.name})));
return Plot.plot({
className: "cfd-plot", width, height: 280, marginLeft: 44, marginRight: 16,
x: {label: "Hour of the day (UTC)", domain: [0, 23], ticks: 12},
y: {label: "Output, % of capacity", grid: true, domain: [0, 0.8], tickFormat: v => Math.round(v * 100)},
color: {domain: VAR_SPLIT.map(t => t.name), range: VAR_SPLIT.map(t => t.colour), legend: true},
marks: [
Plot.line(rows, {x: "h", y: "v", stroke: "name", strokeWidth: 2}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(rows, Plot.pointer({x: "h", y: "v", stroke: "name", title: d => `${String(d.h).padStart(2, "0")}:00 · ${d.name}\n${pc(d.v)} of capacity`}))
]
});
}Wind in detail: onshore and offshore
W = D.wind
WT = [{key: "offshore", name: "Offshore wind", colour: techColour.offshore},
{key: "onshore", name: "Onshore wind", colour: techColour.onshore},
{key: "all", name: "All wind", colour: "var(--cfd-ink)"}]
wName = Object.fromEntries(WT.map(t => [t.key, t.name]))
// share of half-hours with output below x, read off a duration curve (pct = share of time at or above each value)
shareBelow = (curve, pcts, x) => {
for (let i = curve.length - 1; i > 0; i--) if (curve[i - 1] >= x) {
const [a, b] = [curve[i - 1], curve[i]], f = a === b ? 0 : (a - x) / (a - b);
return (100 - (pcts[i - 1] + f * (pcts[i] - pcts[i - 1]))) / 100;
}
return curve[0] < x ? 1 : 0;
}
windLatest = new Date(W.latest)
partial = y => y === windLatest.getUTCFullYear()
yearLabel = y => partial(y) ? `${y} (to ${windLatest.toLocaleDateString("en-GB", {day: "numeric", month: "short", timeZone: "UTC"})})` : String(y)
hrs = h => h >= 48 ? `${f1(h / 24)} days` : `${Math.round(h)} hours`html`<p>NESO's figures put all wind together. To separate it, this section splits NESO's half-hourly wind total by the onshore and offshore shares in Elexon's own half-hourly figures (its B1630 data), then divides each by the capacity installed at the time, scaled so each year matches DESNZ's load factor for that technology. Elexon's split has gaps and glitches in some years, so only the years where it tracks NESO's total closely are shown: ${W.years.map(yearLabel).join(", ")}.</p>`Year and season
Compare
viewof wMode = Inputs.radio(["Onshore against offshore", "Every year"], {value: "Onshore against offshore", label: "Duration curves"})
viewof wRef = Inputs.checkbox(["Nuclear fleet that year", "Hinkley C / Sizewell C"], {value: ["Nuclear fleet that year", "Hinkley C / Sizewell C"], label: "Show for reference"})curveOf = (key, y, season = wSeason) => season === "All year"
? W.duration[key][y].map((v, i) => ({p: W.pct[i], v}))
: (W.season[key][y]?.[season] ?? []).map((v, i) => ({p: i, v}))
wStat = WT.map(t => {
const c = curveOf(t.key, wYear), at = p => c.find(d => d.p >= p)?.v ?? NaN;
const vals = c.map(d => d.v), pcts = c.map(d => d.p);
return {...t, p90: at(90), p50: at(50), p10: at(10), below: shareBelow(vals, pcts, wTh / 100), mean: d3.mean(vals)};
})
ws = Object.fromEntries(wStat.map(s => [s.key, s]))html`<p>In ${yearLabel(wYear)}${wSeason === "All year" ? "" : `, ${wSeason.toLowerCase().replace(/ \(.*/, "")}`}, offshore wind was above <strong>${pc(ws.offshore.p50)}</strong> of its capacity half the time and onshore above <strong>${pc(ws.onshore.p50)}</strong>. Output was below ${wTh}% of capacity in <strong>${pc(ws.offshore.below)}</strong> of half-hours offshore and <strong>${pc(ws.onshore.below)}</strong> onshore, and <strong>${pc(ws.all.below)}</strong> for all wind together. ${W.corr[wYear] != null ? `Onshore and offshore output rise and fall together (a correlation of ${W.corr[wYear].toFixed(2)} across the year's half-hours), so having both only partly fills each other's gaps: the combined fleet still drops below 10% for ${hrs(W.spells.all[wYear]["0.1"].longest)} at a stretch${W.worst[wYear] ? `, from ${new Date(W.worst[wYear].start + "Z").toLocaleDateString("en-GB", {day: "numeric", month: "long", timeZone: "UTC"})}` : ""}.` : ""}</p>`html`<div class="stat-row">
<div class="stat"><span class="stat-value">${pc(ws.offshore.p90)}</span><span class="stat-label">offshore wind output exceeded in 90% of half-hours</span></div>
<div class="stat"><span class="stat-value">${pc(ws.onshore.p90)}</span><span class="stat-label">onshore wind output exceeded in 90% of half-hours</span></div>
<div class="stat"><span class="stat-value">${pc(ws.all.p90)}</span><span class="stat-label">all wind together, exceeded in 90% of half-hours</span></div>
<div class="stat"><span class="stat-value">${pc(D.duration.nuclear[wYear]?.[90] ?? NaN)}</span><span class="stat-label">the nuclear fleet, exceeded in 90% of half-hours</span></div>
</div>`Duration curves
html`<p class="chart-sub">${wMode === "Every year"
? `Each year's half-hours sorted from highest output to lowest, for each kind of wind; ${yearLabel(wYear)} is highlighted, the other years are grey.`
: `Every half-hour of ${yearLabel(wYear)}${wSeason === "All year" ? "" : ` (${wSeason.toLowerCase()})`} sorted from highest output to lowest, as a share of installed capacity.`}
The shaded line marks ${wTh}% of capacity.</p>`{
const refs = [];
if (wRef.includes("Nuclear fleet that year") && D.duration.nuclear[wYear] && wSeason === "All year")
refs.push(Plot.line(D.duration.nuclear[wYear].map((v, p) => ({p, v})), {x: "p", y: "v", stroke: techColour.nuclear, strokeWidth: 1.5, strokeDasharray: "1,3"}));
if (wRef.includes("Hinkley C / Sizewell C")) refs.push(Plot.ruleY([eprCf / 100], {stroke: techColour.nuclear, strokeDasharray: "5,4", strokeWidth: 1.5}));
const base = (h) => ({
className: "cfd-plot", width: h ? Math.min(width, Math.max(300, (width - 32) / 3)) : width, height: h ? 260 : 380, marginLeft: 44, marginRight: 12,
x: {label: "Share of half-hours (%)", domain: [0, 100]},
y: {label: "Output, % of capacity", grid: true, domain: [0, 1], tickFormat: v => Math.round(v * 100)}
});
const thr = [Plot.ruleY([wTh / 100], {stroke: "currentColor", strokeOpacity: 0.35, strokeWidth: 6}), Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4})];
if (wMode !== "Every year") {
const rows = WT.flatMap(t => curveOf(t.key, wYear).map(d => ({...d, name: t.name})));
return Plot.plot({...base(false),
color: {domain: WT.map(t => t.name), range: WT.map(t => t.colour), legend: true},
marks: [...thr, ...refs,
Plot.line(rows, {x: "p", y: "v", stroke: "name", strokeWidth: d => d.name === "All wind" ? 2.5 : 2}),
Plot.tip(rows, Plot.pointerX({x: "p", y: "v", stroke: "name", title: d => `${d.name}\nAt least ${pc(d.v)} of capacity in ${f1(d.p)}% of half-hours`}))]
});
}
return html`<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:8px 16px">${WT.map(t => {
const others = W.years.filter(y => y !== wYear && curveOf(t.key, y).length).flatMap(y => curveOf(t.key, y).map(d => ({...d, y})));
const me = curveOf(t.key, wYear).map(d => ({...d, y: wYear}));
return html`<div><p class="chart-sub" style="margin:4px 0"><strong>${t.name}</strong></p>${Plot.plot({...base(true),
marks: [...thr, ...refs,
Plot.line(others, {x: "p", y: "v", z: "y", stroke: "var(--cfd-context)", strokeWidth: 1}),
Plot.line(me, {x: "p", y: "v", stroke: t.colour, strokeWidth: 2.5}),
Plot.tip([...me, ...others], Plot.pointer({x: "p", y: "v", title: d => `${yearLabel(d.y)} · ${t.name}\nAt least ${pc(d.v)} in ${f1(d.p)}% of half-hours`}))]
})}</div>`;
})}</div>`;
}html`<p class="cfd-note">${wRef.includes("Nuclear fleet that year") ? `The dotted red line is the nuclear fleet's own curve that year${wSeason === "All year" ? "" : " (shown for the whole year only)"}; ` : ""}${wRef.includes("Hinkley C / Sizewell C") ? `the dashed red line is Hinkley Point C and Sizewell C at ${eprCf}%, as if they ran flat out between outages.` : ""}</p>`How often is the wind below %?
html`<p class="chart-sub">Share of each year's half-hours with output below ${wTh}% of installed capacity${wSeason === "All year" ? "" : `, ${wSeason.toLowerCase()} only`}. ${partial(W.years.at(-1)) ? `${W.years.at(-1)} is part of a year.` : ""} Gaps are years where Elexon's split isn't reliable enough to use.</p>`{
// every year in the range, so the line breaks (NaN) over the years Elexon's split is left out
const allYears = d3.range(W.years[0], W.years.at(-1) + 1);
const rows = WT.flatMap(t => allYears.map(y => {
const c = W.years.includes(y) ? curveOf(t.key, y) : [];
return {year: y, name: t.name, v: c.length ? shareBelow(c.map(d => d.v), c.map(d => d.p), wTh / 100) : NaN};
}));
return Plot.plot({
className: "cfd-plot", width, height: 300, marginLeft: 44, marginRight: 16,
x: {label: null, tickFormat: d => String(d), ticks: d3.range(W.years[0], W.years.at(-1) + 1)},
y: {label: "Share of half-hours (%)", grid: true, tickFormat: v => Math.round(v * 100), zero: true},
color: {domain: WT.map(t => t.name), range: WT.map(t => t.colour), legend: true},
marks: [
Plot.ruleX([wYear], {stroke: "currentColor", strokeOpacity: 0.15, strokeWidth: 14}),
Plot.line(rows, {x: "year", y: "v", stroke: "name", strokeWidth: 2}),
Plot.dot(rows, {x: "year", y: "v", fill: "name", r: 3.5}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(rows, Plot.pointer({x: "year", y: "v", stroke: "name", title: d => `${yearLabel(d.year)} · ${d.name}\nBelow ${wTh}% in ${pc(d.v)} of half-hours (${fmt.format(d.v * (partial(d.year) ? 0 : 8766))}${partial(d.year) ? "" : " hours"})`}))
]
});
}Low-wind spells and swings,
html`<div class="nvr-wrap"><table class="nvr-table">
<thead><tr><th></th><th>Longest spell below 5%</th><th>Below 10%</th><th>Below 20%</th><th>Spells of a day or more below 10%</th><th>Hours in them</th><th>Biggest fall in an hour</th><th>Biggest fall in 6 hours</th></tr></thead>
<tbody>${WT.map(t => { const s = W.spells[t.key][wYear], x = W.stats[t.key][wYear]; return html`<tr><td><span class="sw" style="background:${t.colour}"></span>${t.name}</td>
<td>${hrs(s["0.05"].longest)}</td><td>${hrs(s["0.1"].longest)}</td><td>${hrs(s["0.2"].longest)}</td>
<td>${s["0.1"].days}</td><td>${fmt.format(s["0.1"].hours_in_long)}</td><td>${pc(x.drop1h)} of capacity</td><td>${pc(x.drop6h)} of capacity</td></tr>`; })}</tbody>
</table></div>`html`<p class="cfd-note">A spell is a run of consecutive half-hours below the threshold, across the whole year (not just the season chosen). Falls are the largest drop in output, as a share of capacity, over one hour and six hours. For scale, ${f1(D.renew_cap_latest.wind)} GW of wind is installed now: a fall of ${pc(W.stats.all[wYear].drop6h)} of that is ${f1(W.stats.all[wYear].drop6h * D.renew_cap_latest.wind)} GW in six hours.</p>`By season
html`<p class="chart-sub">Duration curves for each season of ${yearLabel(wYear)}, for the kind of wind chosen. Winter, when demand peaks, is the windiest; summer has the most still hours.</p>`{
const seasons = Object.keys(W.season[wSeasonTech][wYear] ?? {});
const ramp = ["#2f6aa3", "#2a9d78", "#d9a21b", "#d0663a"];
const all = ["Winter (Dec–Feb)", "Spring (Mar–May)", "Summer (Jun–Aug)", "Autumn (Sep–Nov)"];
const rows = seasons.flatMap(s => W.season[wSeasonTech][wYear][s].map((v, p) => ({p, v, s})));
return Plot.plot({
className: "cfd-plot", width, height: 320, marginLeft: 44, marginRight: 16,
x: {label: "Share of half-hours (%)", domain: [0, 100]},
y: {label: "Output, % of capacity", grid: true, domain: [0, 1], tickFormat: v => Math.round(v * 100)},
color: {domain: all, range: ramp, legend: true},
marks: [
Plot.ruleY([wTh / 100], {stroke: "currentColor", strokeOpacity: 0.35, strokeWidth: 6}),
Plot.line(rows, {x: "p", y: "v", stroke: "s", strokeWidth: 2}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(rows, Plot.pointerX({x: "p", y: "v", stroke: "s", title: d => `${d.s}\nAt least ${pc(d.v)} in ${d.p}% of half-hours`}))
]
});
}Every year
html`<div class="nvr-wrap"><table class="nvr-table">
<thead><tr><th>Year</th>${TECH.map(t => html`<th><span class="sw" style="background:${t.colour}"></span>${t.name}<br>GW · TWh · %</th>`)}</tr></thead>
<tbody>${D.annual.slice().reverse().map(r => html`<tr><td>${r.year}</td>${TECH.map(t => html`<td>${f1(r[t.key].gw)} · ${f1(r[t.key].twh)} · ${f1(r[t.key].lf)}%</td>`)}</tr>`)}</tbody>
</table></div>`{
const csv = d3.csvFormat(D.annual.flatMap(r => TECH.map(t => ({year: r.year, technology: t.name, capacity_gw: r[t.key].gw, generation_twh: r[t.key].twh, load_factor_pct: r[t.key].lf}))));
const a = html`<a download="uk-nuclear-vs-renewables-annual.csv">Download this table as CSV</a>`;
a.href = URL.createObjectURL(new Blob([csv], {type: "text/csv"}));
return html`<p class="cfd-note" style="margin-top:8px">${a}. Capacity is at the end of each year; load factors are DESNZ's, on the average of start and end of year capacity for renewables.</p>`;
}Notes on the data
- Renewables (capacity, generation and load factors) are from DESNZ’s Energy Trends table 6.1, UK-wide. Offshore wind includes the small floating fleet; hydro is small and large scale combined; plant biomass includes Drax’s converted units.
- Nuclear capacity and load factor are from DUKES tables 5.7 and 5.10. Generation per GW is the load factor × 8,766 hours (an average year).
- Half-hourly and daily variability uses NESO’s historic generation mix, GB only: metered transmission output plus NESO’s estimates of embedded wind and solar. Each half-hour is divided by the capacity installed at the time (DESNZ’s quarterly figures, and the dates each nuclear reactor closed), scaled so each year’s average matches DESNZ’s published load factor. The latest, part year uses the last full year’s scaling. NESO’s solar estimates start in 2013, and its early years count little of the embedded wind, so the 2009–2010 wind shape is the least reliable.
- Hinkley Point C and Sizewell C are two 1.63 GW EPR units each. The 90% default is the capacity factor in Hinkley Point C’s CfD analysis on this site and close to the EPR’s design availability; no EPR has yet run for long enough in Europe to show what it will achieve. EDF expects Hinkley Point C’s first unit in 2030; Sizewell C reached its final investment decision in July 2025 with no published start date.
- Existing fleet dates: EDF plans to run Heysham 1, Hartlepool, Heysham 2 and Torness to March 2030 and Sizewell B to 2055 (EDF); those dates depend on graphite inspections and the regulator.
- Onshore and offshore wind (the wind-in-detail section): NESO’s wind total, split by the offshore share in Elexon’s actual or estimated wind and solar generation (B1630, AGWS). Single-period glitches, and half-hours where Elexon’s total sits more than 35% from its usual ratio to NESO’s, are dropped and the share carried across the gap. Years where Elexon’s total correlates with NESO’s below 0.95, or with more than 15% of half-hours missing, are left out (2016–2017, 2022–2024).
- Matching annual energy is not the same as replacing a station: wind and solar would also need storage, interconnectors or backup for the still, dark spells shown above, and nuclear needs flexible plant for its outages. The CfD comparison looks at the cost side.
- Built by
nuclear-vs-renewables/prepare.pyand refreshed when the site is rebuilt.