Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions scripts/build-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import {
ENTSOE_DOMAINS, ENTSOE_RENEWABLE_PSR, ESO_RENEWABLE, EIA_RENEWABLE,
EIA_RESPONDENT, ESO_COUNTRY,
ENTSOE_DOMAINS, ENTSOE_RENEWABLE_PSR, ENTSOE_FOSSIL_PSR, ENTSOE_NUCLEAR_PSR,
ENTSOE_WIND_PSR, ENTSOE_SOLAR_PSR, ESO_RENEWABLE, ESO_FOSSIL, EIA_RENEWABLE,
EIA_FOSSIL, EIA_RESPONDENT, ESO_COUNTRY,
} from "./live-sources.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand All @@ -41,12 +42,19 @@ async function fetchEso() {
const gen = JSON.parse(await fetchText("https://api.carbonintensity.org.uk/generation"));
const mix = gen.data.generationmix;
const renew = mix.filter((m) => ESO_RENEWABLE.has(m.fuel)).reduce((s, m) => s + m.perc, 0);
// perc values are already percentages of the live mix.
const perc = (fuel) => mix.find((m) => m.fuel === fuel)?.perc ?? null;
const fossil = mix.filter((m) => ESO_FOSSIL.has(m.fuel)).reduce((s, m) => s + m.perc, 0);
let carbon = null;
try {
const ci = JSON.parse(await fetchText("https://api.carbonintensity.org.uk/intensity"));
carbon = ci.data?.[0]?.intensity?.actual ?? ci.data?.[0]?.intensity?.forecast ?? null;
} catch { /* carbon is optional */ }
return { renewable: round1(renew), carbon, source: "National Energy System Operator (UK)", at: gen.data.to };
return {
renewable: round1(renew), carbon,
wind: perc("wind"), solar: perc("solar"), nuclear: perc("nuclear"), fossil: round1(fossil),
source: "National Energy System Operator (UK)", at: gen.data.to,
};
}

// ---- EIA (US) — needs EIA_KEY ----------------------------------------------
Expand All @@ -59,15 +67,24 @@ async function fetchEia(key, respondent) {
if (!rows.length) throw new Error("EIA: no rows");
const latest = rows[0].period;
const hour = rows.filter((r) => r.period === latest);
let total = 0, renew = 0;
let total = 0, renew = 0, wind = 0, solar = 0, nuclear = 0, fossil = 0;
for (const r of hour) {
const v = +r.value;
if (!Number.isFinite(v) || v < 0) continue;
total += v;
if (EIA_RENEWABLE.has(r.fueltype)) renew += v;
if (r.fueltype === "WND") wind += v;
if (r.fueltype === "SUN") solar += v;
if (r.fueltype === "NUC") nuclear += v;
if (EIA_FOSSIL.has(r.fueltype)) fossil += v;
}
if (total <= 0) throw new Error("EIA: zero total");
return { renewable: round1((renew / total) * 100), carbon: null, source: "U.S. EIA Grid Monitor", at: `${latest}:00Z` };
const pct = (v) => round1((v / total) * 100);
return {
renewable: pct(renew), carbon: null,
wind: pct(wind), solar: pct(solar), nuclear: pct(nuclear), fossil: pct(fossil),
source: "U.S. EIA Grid Monitor", at: `${latest}:00Z`,
};
}

// ---- ENTSO-E (EU) — needs ENTSOE_TOKEN -------------------------------------
Expand Down Expand Up @@ -99,14 +116,19 @@ function parseEntsoe(xml) {
}
if (bestQty != null) byType[psr] = (byType[psr] || 0) + bestQty;
}
let total = 0, renew = 0;
let total = 0, renew = 0, wind = 0, solar = 0, nuclear = 0, fossil = 0;
for (const [psr, q] of Object.entries(byType)) {
if (psr === "B10") continue; // pumped-storage generation excluded
total += q;
if (ENTSOE_RENEWABLE_PSR.has(psr)) renew += q;
if (ENTSOE_WIND_PSR.has(psr)) wind += q;
if (ENTSOE_SOLAR_PSR.has(psr)) solar += q;
if (ENTSOE_NUCLEAR_PSR.has(psr)) nuclear += q;
if (ENTSOE_FOSSIL_PSR.has(psr)) fossil += q;
}
if (total <= 0) throw new Error("ENTSO-E: zero total");
return round1((renew / total) * 100);
const pct = (v) => round1((v / total) * 100);
return { renewable: pct(renew), wind: pct(wind), solar: pct(solar), nuclear: pct(nuclear), fossil: pct(fossil) };
}

async function fetchEntsoe(token, domain) {
Expand All @@ -116,7 +138,7 @@ async function fetchEntsoe(token, domain) {
+ `&periodStart=${periodStart}&periodEnd=${periodEnd}`;
const xml = await fetchText(url);
if (xml.includes("Acknowledgement_MarketDocument")) throw new Error("ENTSO-E: acknowledgement (no data / bad token)");
return { renewable: parseEntsoe(xml), carbon: null, source: "ENTSO-E Transparency Platform", at: `${periodEnd.slice(0, 8)}T${periodEnd.slice(8, 12)}Z` };
return { ...parseEntsoe(xml), carbon: null, source: "ENTSO-E Transparency Platform", at: `${periodEnd.slice(0, 8)}T${periodEnd.slice(8, 12)}Z` };
}

// ---- main ------------------------------------------------------------------
Expand Down
18 changes: 18 additions & 0 deletions scripts/live-sources.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,30 @@ export const ENTSOE_RENEWABLE_PSR = new Set([
"B19", // Wind Onshore
]);

// ENTSO-E PSR breakdown groups (sub-shares of the same generation total).
export const ENTSOE_FOSSIL_PSR = new Set([
"B02", // Fossil Brown coal/Lignite
"B03", // Fossil Coal-derived gas
"B04", // Fossil Gas
"B05", // Fossil Hard coal
"B06", // Fossil Oil
"B07", // Fossil Oil shale
"B08", // Fossil Peat
]);
export const ENTSOE_NUCLEAR_PSR = new Set(["B14"]); // Nuclear
export const ENTSOE_WIND_PSR = new Set(["B18", "B19"]); // Wind Offshore + Onshore
export const ENTSOE_SOLAR_PSR = new Set(["B16"]); // Solar

// ESO (UK National Energy System Operator) generation-mix fuels counted as renewable.
export const ESO_RENEWABLE = new Set(["biomass", "hydro", "solar", "wind"]);
// ESO fossil fuels (wind/solar/nuclear are single literal fuel names in the mix).
export const ESO_FOSSIL = new Set(["coal", "gas", "oil"]);

// EIA RTO fuel-type ids counted as renewable. (Biomass/geothermal fall under the
// aggregated "OTH" bucket and are excluded to avoid over-counting.)
export const EIA_RENEWABLE = new Set(["SUN", "WND", "WAT"]);
// EIA fossil fuel-type ids (wind=WND, solar=SUN, nuclear=NUC are single ids).
export const EIA_FOSSIL = new Set(["COL", "NG", "OIL"]);

// match-name → EIA respondent (balancing-authority) code.
export const EIA_RESPONDENT = { "United States of America": "US" };
Expand Down
9 changes: 9 additions & 0 deletions src/components/CountryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ export function CountryPanel({ rec, metric, scales, onClose, onPin, isPinned }:
<span style={{ fontSize: 11.5, color: "var(--text-3)" }}>· <span className="mono tnum" style={{ color: scales.carbon(rec.live.carbon) }}>{Math.round(rec.live.carbon)}</span> gCO₂/kWh</span>
)}
</div>
{(rec.live.wind != null || rec.live.solar != null || rec.live.nuclear != null || rec.live.fossil != null) && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px 10px", fontSize: 11, color: "var(--text-3)", marginTop: 3 }}>
{([["Wind", rec.live.wind], ["Solar", rec.live.solar], ["Nuclear", rec.live.nuclear], ["Fossil", rec.live.fossil]] as const)
.filter(([, v]) => v != null)
.map(([label, v]) => (
<span key={label}>{label} <span className="mono tnum" style={{ color: "var(--text-2)" }}>{Math.round(v as number)}%</span></span>
))}
</div>
)}
<div style={{ fontSize: 10.5, color: "var(--text-3)", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{rec.live.source} · {fmtLiveTime(rec.live.at)}</div>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface EnergyMix {
export interface LivePoint {
renewable: number | null;
carbon: number | null;
/** Live generation-mix sub-shares (% of generation now); null where unavailable. */
wind: number | null;
solar: number | null;
nuclear: number | null;
fossil: number | null;
source: string;
/** ISO timestamp of the upstream interval. */
at: string;
Expand Down