From ffd0afef49244b96ec7734c0d5542cabcfd2f4a0 Mon Sep 17 00:00:00 2001 From: aurph Date: Tue, 1 Sep 2026 11:53:16 -0400 Subject: [PATCH 1/3] My Grid: your grid right now (live demand + fuel mix, keyless ISO feeds) Competitive research (Epoch, Cleanview, SemiAnalysis, GridStatus, EIA Grid Monitor, Electricity Maps, Ember) found one thing every live-grid product has that My Grid lacked: today's actual demand curve. Nobody serves it state-first in plain language for the bill-payer - that lane is GridTilt's. server/grid-live.ts: pure parsers per ISO + a 5-minute cache over each operator's own keyless public feeds (ERCOT dashboard JSON, CAISO Today's Outlook CSVs, MISO public API - the same 5-minute data behind their official dashboards). Normalized snapshot: today's demand actuals, current vs peak-so-far, latest-interval fuel mix folded onto the sitewide fuel vocabulary. GET /api/grid-live/:rto, public; unsupported regions 404 with the supported list; upstream blips serve a recent stale snapshot (max 30 min) before erroring honestly. My Grid renders "Your grid right now" for TX (ERCOT), CA (CAISO), and the 15 MISO states: plain-language headline, demand line, fuel rows (color dots reuse CATEGORY_COLORS; labels carry identity), source + as-of provenance. Other regions show nothing until EIA_API_KEY lights up the EIA path for full coverage. Three real feed bugs caught by driving it live and locked in tests: ERCOT future rows carry populated demand flagged forecast:1 (peak was silently the forecast peak); MISO FuelMix returns every interval of the day (folding read ~4 TW of gas); MISO's freshest interval streams in partially (only Imports present), so the parser picks the latest complete interval. 491 tests (7 new), tsc, build green; live-verified against all three ISOs (ERCOT 72.4 GW, CAISO 26.5 GW, MISO 103.6 GW at commit time). --- client/src/pages/my-grid.tsx | 156 ++++++++++++++++- server/__tests__/grid-live.test.ts | 163 ++++++++++++++++++ server/grid-live.ts | 264 +++++++++++++++++++++++++++++ server/routes.ts | 21 +++ 4 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 server/__tests__/grid-live.test.ts create mode 100644 server/grid-live.ts diff --git a/client/src/pages/my-grid.tsx b/client/src/pages/my-grid.tsx index b94c152..503da23 100644 --- a/client/src/pages/my-grid.tsx +++ b/client/src/pages/my-grid.tsx @@ -19,7 +19,7 @@ import { AsOf, ErrorState, SrChartTable } from "@/components/Freshness"; import { PageHeader } from "@/components/PageHeader"; import { RTO_CONFIG, RTO_SOURCE_NOTE, type RTOConfig } from "@/data/rto-config"; import { STATE_GRID, STATE_GRID_SOURCE } from "@/data/state-grid"; -import { BORDER, BRAND, FONT, INK, SEMANTIC, STATUS_COLORS, SURFACE } from "@/lib/tokens"; +import { BORDER, BRAND, CATEGORY_COLORS, FONT, INK, SEMANTIC, STATUS_COLORS, SURFACE } from "@/lib/tokens"; import { seriesMotion, axisProps, gridProps, tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle, } from "@/lib/chart-theme"; // US state boundaries: US Census cartographic boundary file (public domain), // via the widely used us-states GeoJSON distribution. @@ -79,6 +79,49 @@ interface QueueResponse { projects: QueueProject[]; } +// ── Live grid snapshot (server proxies each ISO's keyless public feeds) ── + +interface LiveFuelSlice { + fuel: string; + mw: number; +} +interface GridLiveSnapshot { + rto: string; + operator: string; + asOf: string; + demand: { time: string; mw: number }[]; + currentDemandMW: number; + peakDemandMW: number; + fuelMix: LiveFuelSlice[]; + source: string; + sourceUrl: string; +} + +/** States with a keyless live feed today. Everything else joins via EIA + * hourly data once EIA_API_KEY is configured. */ +function liveRtoFor(state: string, region: string | null): "ercot" | "caiso" | "miso" | null { + if (state === "TX") return "ercot"; + if (state === "CA") return "caiso"; + if (region === "MISO") return "miso"; + return null; +} + +// Dot colors reuse the sitewide energy vocabulary; unlisted fuels stay muted +// and every row is labeled, so color never carries identity alone. +const LIVE_FUEL_COLOR: Record = { + gas: CATEGORY_COLORS.gas, + coal: CATEGORY_COLORS.coal, + nuclear: CATEGORY_COLORS.nuclear, + wind: CATEGORY_COLORS.wind, + solar: CATEGORY_COLORS.solar, + hydro: CATEGORY_COLORS.hydro, + storage: CATEGORY_COLORS.storage, +}; + +const fmtGw = (mw: number) => `${(mw / 1000).toFixed(1)} GW`; +const fmtFuelMw = (mw: number) => + Math.abs(mw) >= 1000 ? fmtGw(mw) : `${Math.round(mw).toLocaleString()} MW`; + /** Which LBNL queue buckets correspond to a state's mapped region. */ const QUEUE_ISOS: Record = { PJM: ["PJM"], @@ -268,6 +311,22 @@ export default function MyGrid() { const grid = state ? STATE_GRID[state] : null; const rto = grid?.region ? RTO_CONFIG[grid.region] : null; + const liveRto = grid ? liveRtoFor(state, grid.region) : null; + const { + data: live, + isError: liveError, + refetch: refetchLive, + } = useQuery({ + queryKey: [`/api/grid-live/${liveRto}`], + enabled: !!liveRto, + refetchInterval: 5 * 60 * 1000, + queryFn: async () => { + const res = await fetch(`/api/grid-live/${liveRto}`); + if (!res.ok) throw new Error(`grid-live ${res.status}`); + return res.json(); + }, + }); + const localFacilities = useMemo(() => { if (!state) return []; return facilities @@ -449,6 +508,101 @@ export default function MyGrid() { + {liveRto && ( + +
+ Your grid right now +
+ {liveError ? ( + refetchLive()} + className="h-[200px]" + /> + ) : !live ? ( +
+ +
+ ) : ( +
+
+

+ {grid.name} is drawing{" "} + {fmtGw(live.currentDemandMW)}{" "} + right now · today's peak so far{" "} + {fmtGw(live.peakDemandMW)} +

+
+ + + + + `${Math.round(v / 1000)}`} + label={{ value: "GW", position: "insideTopLeft", offset: 8, fill: INK.muted, fontSize: 10 }} + /> + [fmtGw(v), "demand"]} + /> + + + +
+ i % 12 === 0).map((d) => [d.time, d.mw])} + /> +
+
+ What's generating it +
+ {live.fuelMix.map((f) => ( +
+ + + {f.fuel} + + {fmtFuelMw(f.mw)} +
+ ))} +
+
+
+ )} + {live && !liveError && ( +
+ 5-minute data ·{" "} + + {live.source} + {" "} + · updated {live.asOf} +
+ )} +
+ )} +
diff --git a/server/__tests__/grid-live.test.ts b/server/__tests__/grid-live.test.ts new file mode 100644 index 0000000..e63e6fd --- /dev/null +++ b/server/__tests__/grid-live.test.ts @@ -0,0 +1,163 @@ +// Pure-parser tests for the live grid snapshot. Fixtures mirror the real +// payload shapes captured from each feed on 2026-09-01 (trimmed to +// hand-computable size); the fetch/cache layer is not network-tested here. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + normalizeFuel, + foldFuel, + parseErcot, + parseCaiso, + parseMiso, + misoIntervalKey, + isLiveRto, + LIVE_RTOS, +} from "../grid-live"; + +test("normalizeFuel folds source vocabularies onto canonical keys", () => { + assert.equal(normalizeFuel("Natural Gas"), "gas"); + assert.equal(normalizeFuel("Coal and Lignite"), "coal"); + assert.equal(normalizeFuel("Power Storage"), "storage"); + assert.equal(normalizeFuel("Battery Storage"), "storage"); + assert.equal(normalizeFuel("Batteries"), "storage"); + assert.equal(normalizeFuel("Small hydro"), "hydro"); + assert.equal(normalizeFuel("Large Hydro"), "hydro"); + assert.equal(normalizeFuel("Biogas"), "biomass"); + assert.equal(normalizeFuel("Imports"), "imports"); + assert.equal(normalizeFuel("Other"), "other"); +}); + +test("foldFuel merges same-canonical slices and sorts by MW desc", () => { + const out = foldFuel([ + { fuel: "hydro", mw: 215 }, + { fuel: "hydro", mw: 2390 }, + { fuel: "gas", mw: 9701 }, + { fuel: "storage", mw: -865.9 }, + ]); + assert.deepEqual(out, [ + { fuel: "gas", mw: 9701 }, + { fuel: "hydro", mw: 2605 }, + { fuel: "storage", mw: -866 }, + ]); +}); + +test("parseErcot: actuals only, latest day + interval for fuel, peak and current", () => { + const supplyDemand = { + lastUpdated: "2026-09-01 10:40:00-0500", + data: [ + { demand: 67304, forecast: 0, timestamp: "2026-09-01 00:00:00-0500" }, + { demand: 71210.4, forecast: 0, timestamp: "2026-09-01 00:05:00-0500" }, + // Future rows keep a populated demand but are flagged forecast:1 — + // they must not leak into "today's peak so far" (real bug caught live). + { demand: 85553, forecast: 1, timestamp: "2026-09-01 16:40:00-0500" }, + { demand: 0, forecast: 0, timestamp: "2026-09-01 00:10:00-0500" }, + ], + }; + const fuelMix = { + data: { + "2026-08-31": { "2026-08-31 23:55:00-0500": { Wind: { gen: 1 } } }, + "2026-09-01": { + "2026-09-01 10:35:00-0500": { Wind: { gen: 100 } }, + "2026-09-01 10:40:00-0500": { + "Natural Gas": { gen: 29495.6 }, + "Coal and Lignite": { gen: 9494.5 }, + "Power Storage": { gen: -865.9 }, + }, + }, + }, + }; + const s = parseErcot(supplyDemand, fuelMix); + assert.equal(s.asOf, "2026-09-01 10:40:00-0500"); + assert.deepEqual(s.demand, [ + { time: "00:00", mw: 67304 }, + { time: "00:05", mw: 71210 }, + ]); + assert.equal(s.currentDemandMW, 71210); + assert.equal(s.peakDemandMW, 71210); + assert.deepEqual(s.fuelMix, [ + { fuel: "gas", mw: 29496 }, + { fuel: "coal", mw: 9495 }, + { fuel: "storage", mw: -866 }, + ]); +}); + +test("parseCaiso: skips empty-actual future rows; folds hydro columns", () => { + const demandCsv = [ + "Time,Day ahead forecast,Hour ahead forecast,Current demand,Demand response", + "00:00,28618,27050,26956,", + "00:05,26794,27050,27121,", + "00:10,26794,27050,,", // future: no actual yet + ].join("\n"); + const fuelCsv = [ + "Time,Solar,Wind,Small hydro,Large Hydro,Natural Gas,Batteries", + "00:00,-64,3421,215,2390,9701,2026", + "00:05,-65,3423,,,,", // incomplete row must be ignored + ].join("\n"); + const s = parseCaiso(demandCsv, fuelCsv); + assert.deepEqual(s.demand, [ + { time: "00:00", mw: 26956 }, + { time: "00:05", mw: 27121 }, + ]); + assert.equal(s.currentDemandMW, 27121); + assert.equal(s.peakDemandMW, 27121); + assert.deepEqual(s.fuelMix, [ + { fuel: "gas", mw: 9701 }, + { fuel: "wind", mw: 3421 }, + { fuel: "hydro", mw: 2605 }, + { fuel: "storage", mw: 2026 }, + { fuel: "solar", mw: -64 }, + ]); + assert.equal(s.asOf, "00:00 PT"); // last complete fuel row stamps the mix +}); + +test("parseMiso: five-minute load series + comma-tolerant fuel numbers", () => { + const load = { + LoadInfo: { + RefId: "01-Sep-2026 - Interval 10:35 EST", + FiveMinTotalLoad: [ + { Load: { Time: "00:00", Value: "87744" } }, + { Load: { Time: "00:05", Value: "87394" } }, + ], + }, + }; + const fuel = { + Fuel: { + Type: [ + // An earlier interval of the same day: must be excluded, not summed + // (the feed returns every interval; folding them read ~4 TW of gas). + { INTERVALEST: "2026-09-01 12:00:00 AM", CATEGORY: "Coal", ACT: "30000" }, + { INTERVALEST: "2026-09-01 12:00:00 AM", CATEGORY: "Natural Gas", ACT: "29000" }, + { INTERVALEST: "2026-09-01 10:35:00 AM", CATEGORY: "Coal", ACT: "32,290" }, + { INTERVALEST: "2026-09-01 10:35:00 AM", CATEGORY: "Natural Gas", ACT: "31890" }, + { INTERVALEST: "2026-09-01 10:35:00 AM", CATEGORY: "Battery Storage", ACT: "-236" }, + // The freshest interval streams in partially (observed live: only + // Imports present); an incomplete latest interval must be skipped + // in favor of the newest complete one. + { INTERVALEST: "2026-09-01 10:40:00 AM", CATEGORY: "Imports", ACT: "1891" }, + ], + }, + }; + const s = parseMiso(load, fuel); + assert.equal(s.asOf, "01-Sep-2026 - Interval 10:35 EST"); + assert.equal(s.currentDemandMW, 87394); + assert.equal(s.peakDemandMW, 87744); + assert.deepEqual(s.fuelMix, [ + { fuel: "coal", mw: 32290 }, + { fuel: "gas", mw: 31890 }, + { fuel: "storage", mw: -236 }, + ]); +}); + +test("misoIntervalKey orders 12-hour interval stamps correctly", () => { + // Lexical string order would put "1:00 PM" before "2:00 AM"; the key must not. + assert.ok(misoIntervalKey("2026-09-01 1:00:00 PM") > misoIntervalKey("2026-09-01 2:00:00 AM")); + assert.equal(misoIntervalKey("2026-09-01 12:00:00 AM"), 0); // midnight + assert.equal(misoIntervalKey("2026-09-01 12:05:00 PM"), 725); // noon + 5 + assert.equal(misoIntervalKey("garbage"), -1); +}); + +test("route guard vocabulary is exactly the supported set", () => { + assert.deepEqual(LIVE_RTOS, ["ercot", "caiso", "miso"]); + assert.ok(isLiveRto("ercot") && isLiveRto("caiso") && isLiveRto("miso")); + assert.ok(!isLiveRto("pjm") && !isLiveRto("spp") && !isLiveRto("")); +}); diff --git a/server/grid-live.ts b/server/grid-live.ts new file mode 100644 index 0000000..9686396 --- /dev/null +++ b/server/grid-live.ts @@ -0,0 +1,264 @@ +// ─── Live grid snapshot (pure parsers + cached fetch) ──────────────────── +// +// "Your grid right now" for My Grid: today's demand curve and the current +// fuel mix, from each ISO's own public, keyless dashboard feeds (the same +// feeds behind their official dashboards; 5-minute data). Parsers are pure +// and unit-tested against captured shapes; the fetch layer caches for five +// minutes and serves a recent stale snapshot rather than flapping when an +// upstream blips. Coverage is honest: ERCOT, CAISO, and MISO have keyless +// feeds; the remaining regions arrive via EIA once EIA_API_KEY is set. + +import { fetchWithTimeout } from "./fetch-timeout"; + +export type LiveRto = "ercot" | "caiso" | "miso"; +export const LIVE_RTOS: LiveRto[] = ["ercot", "caiso", "miso"]; +export const isLiveRto = (s: string): s is LiveRto => (LIVE_RTOS as string[]).includes(s); + +export interface FuelSlice { + fuel: string; // canonical: gas coal nuclear wind solar hydro storage geothermal biomass imports other + mw: number; // negatives are real (storage charging, solar at night) +} + +export interface GridLiveSnapshot { + rto: LiveRto; + operator: string; + asOf: string; // as reported by the feed, in the grid's local time + demand: { time: string; mw: number }[]; // today's actuals, 5-minute, "HH:MM" + currentDemandMW: number; + peakDemandMW: number; // today's max actual so far + fuelMix: FuelSlice[]; // latest interval, sorted by mw desc + source: string; + sourceUrl: string; +} + +// ── fuel-name normalization ─────────────────────────────────────────────── + +const FUEL_MAP: Array<[RegExp, string]> = [ + [/natural gas/i, "gas"], + [/coal/i, "coal"], + [/nuclear/i, "nuclear"], + [/wind/i, "wind"], + [/solar/i, "solar"], + [/hydro/i, "hydro"], + [/storage|batter/i, "storage"], + [/geothermal/i, "geothermal"], + [/bio/i, "biomass"], + [/import/i, "imports"], +]; + +export function normalizeFuel(raw: string): string { + for (const [re, canon] of FUEL_MAP) if (re.test(raw)) return canon; + return "other"; +} + +/** Merge same-canonical slices (e.g. CAISO's small + large hydro) and sort. */ +export function foldFuel(slices: FuelSlice[]): FuelSlice[] { + const m = new Map(); + for (const s of slices) m.set(s.fuel, (m.get(s.fuel) ?? 0) + s.mw); + return Array.from(m.entries()) + .map(([fuel, mw]) => ({ fuel, mw: Math.round(mw) })) + .sort((a, b) => b.mw - a.mw); +} + +// ── ERCOT ───────────────────────────────────────────────────────────────── +// supply-demand.json: { lastUpdated, data: [{ demand, capacity, forecast, +// timestamp: "YYYY-MM-DD HH:MM:SS-05:00", ... }] }. Future rows keep a +// populated demand field but are flagged forecast:1 — filter on the flag, +// not on demand>0, or "today's peak" silently becomes the forecast peak. +// fuel-mix.json: { lastUpdated, data: { "YYYY-MM-DD": { "": { Fuel: +// { gen } } } } } — carries yesterday too; take latest day, latest interval. + +export function parseErcot( + supplyDemand: { lastUpdated: string; data: Array<{ demand: number; forecast?: number; timestamp: string }> }, + fuelMix: { data: Record>> }, +): Pick { + const demand = (supplyDemand.data ?? []) + .filter((r) => r.demand > 0 && !r.forecast) + .map((r) => ({ time: r.timestamp.slice(11, 16), mw: Math.round(r.demand) })); + + const days = Object.keys(fuelMix.data ?? {}).sort(); + const latestDay = fuelMix.data?.[days[days.length - 1]] ?? {}; + const intervals = Object.keys(latestDay).sort(); + const latest = latestDay[intervals[intervals.length - 1]] ?? {}; + const fuel = foldFuel( + Object.entries(latest).map(([name, v]) => ({ fuel: normalizeFuel(name), mw: v.gen })), + ); + + return { + asOf: supplyDemand.lastUpdated, + demand, + currentDemandMW: demand[demand.length - 1]?.mw ?? 0, + peakDemandMW: demand.reduce((m, r) => Math.max(m, r.mw), 0), + fuelMix: fuel, + }; +} + +// ── CAISO ───────────────────────────────────────────────────────────────── +// outlook/current/demand.csv: Time,Day ahead forecast,Hour ahead forecast, +// Current demand,Demand response — future rows have an empty actual. +// outlook/current/fuelsource.csv: Time, — take the last row +// with a complete set of values. + +function parseCsv(text: string): { header: string[]; rows: string[][] } { + const lines = text.trim().split(/\r?\n/); + const header = (lines[0] ?? "").split(",").map((h) => h.trim()); + const rows = lines.slice(1).map((l) => l.split(",").map((c) => c.trim())); + return { header, rows }; +} + +export function parseCaiso( + demandCsv: string, + fuelCsv: string, +): Pick { + const d = parseCsv(demandCsv); + const demandCol = d.header.findIndex((h) => /current demand/i.test(h)); + const demand = d.rows + .filter((r) => r[demandCol] !== "" && r[demandCol] != null) + .map((r) => ({ time: r[0], mw: Math.round(Number(r[demandCol])) })) + .filter((r) => Number.isFinite(r.mw)); + + const f = parseCsv(fuelCsv); + const complete = f.rows.filter((r) => r.length === f.header.length && r.slice(1).every((c) => c !== "")); + const last = complete[complete.length - 1] ?? []; + const fuel = foldFuel( + f.header.slice(1).map((name, i) => ({ fuel: normalizeFuel(name), mw: Number(last[i + 1] ?? 0) })), + ); + + return { + asOf: `${last[0] ?? demand[demand.length - 1]?.time ?? ""} PT`, + demand, + currentDemandMW: demand[demand.length - 1]?.mw ?? 0, + peakDemandMW: demand.reduce((m, r) => Math.max(m, r.mw), 0), + fuelMix: fuel, + }; +} + +// ── MISO ────────────────────────────────────────────────────────────────── +// RealTimeTotalLoad: { LoadInfo: { RefId, FiveMinTotalLoad: [{ Load: +// { Time: "HH:MM", Value: "87744" } }] } } +// FuelMix/Today: { RefId, Fuel: { Type: [{ INTERVALEST, CATEGORY, ACT }] } } +// — Type[] carries EVERY interval of the day; keep only the latest one or +// the "current" mix silently becomes a day-total (a ~4 TW gas reading). + +/** "2026-09-01 1:05:00 PM" -> sortable minutes-of-day (12-hour source). */ +export function misoIntervalKey(intervalEst: string): number { + const m = /(\d{1,2}):(\d{2}):\d{2}\s*(AM|PM)/i.exec(intervalEst); + if (!m) return -1; + let h = Number(m[1]) % 12; + if (/pm/i.test(m[3])) h += 12; + return h * 60 + Number(m[2]); +} + +export function parseMiso( + loadJson: { LoadInfo: { RefId: string; FiveMinTotalLoad: Array<{ Load: { Time: string; Value: string } }> } }, + fuelJson: { Fuel: { Type: Array<{ INTERVALEST?: string; CATEGORY: string; ACT: string }> } }, +): Pick { + const demand = (loadJson.LoadInfo?.FiveMinTotalLoad ?? []) + .map((r) => ({ time: r.Load.Time, mw: Math.round(Number(r.Load.Value)) })) + .filter((r) => Number.isFinite(r.mw) && r.mw > 0); + + // The newest interval streams in category-by-category (observed live: the + // freshest stamp held only "Imports"). Use the latest interval that is + // reasonably complete relative to the fullest interval of the day. + const types = fuelJson.Fuel?.Type ?? []; + const counts = new Map(); + for (const t of types) { + const k = misoIntervalKey(t.INTERVALEST ?? ""); + counts.set(k, (counts.get(k) ?? 0) + 1); + } + const maxCount = Math.max(0, ...Array.from(counts.values())); + const pickKey = Array.from(counts.entries()) + .filter(([, n]) => n >= maxCount * 0.8) + .reduce((m, [k]) => Math.max(m, k), -1); + const fuel = foldFuel( + types + .filter((t) => misoIntervalKey(t.INTERVALEST ?? "") === pickKey) + .map((t) => ({ + fuel: normalizeFuel(t.CATEGORY), + mw: Number(String(t.ACT).replace(/,/g, "")), + })), + ); + + return { + asOf: loadJson.LoadInfo?.RefId ?? "", + demand, + currentDemandMW: demand[demand.length - 1]?.mw ?? 0, + peakDemandMW: demand.reduce((m, r) => Math.max(m, r.mw), 0), + fuelMix: fuel, + }; +} + +// ── fetch + cache ───────────────────────────────────────────────────────── + +const FEEDS: Record = { + ercot: { + operator: "ERCOT", + source: "ERCOT public dashboard feeds", + sourceUrl: "https://www.ercot.com/gridmktinfo/dashboards", + urls: [ + "https://www.ercot.com/api/1/services/read/dashboards/supply-demand.json", + "https://www.ercot.com/api/1/services/read/dashboards/fuel-mix.json", + ], + }, + caiso: { + operator: "CAISO", + source: "CAISO Today's Outlook feeds", + sourceUrl: "https://www.caiso.com/todays-outlook", + urls: [ + "https://www.caiso.com/outlook/current/demand.csv", + "https://www.caiso.com/outlook/current/fuelsource.csv", + ], + }, + miso: { + operator: "MISO", + source: "MISO public API", + sourceUrl: "https://www.misoenergy.org/markets-and-operations/real-time--market-data/", + urls: [ + "https://public-api.misoenergy.org/api/RealTimeTotalLoad", + "https://public-api.misoenergy.org/api/FuelMix/Today", + ], + }, +}; + +const CACHE_MS = 5 * 60 * 1000; +const STALE_OK_MS = 30 * 60 * 1000; +const cache = new Map(); + +async function fetchText(url: string): Promise { + const res = await fetchWithTimeout(url, { headers: { accept: "application/json, text/csv, */*" } }, 10_000); + if (!res.ok) throw new Error(`${url} -> ${res.status}`); + return res.text(); +} + +export async function getGridLive(rto: LiveRto): Promise { + const hit = cache.get(rto); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.snap; + + try { + const feed = FEEDS[rto]; + const [a, b] = await Promise.all(feed.urls.map(fetchText)); + let parsed; + if (rto === "ercot") parsed = parseErcot(JSON.parse(a), JSON.parse(b)); + else if (rto === "caiso") parsed = parseCaiso(a, b); + else parsed = parseMiso(JSON.parse(a), JSON.parse(b)); + if (parsed.demand.length === 0) throw new Error("feed returned no demand actuals"); + const snap: GridLiveSnapshot = { + rto, + operator: feed.operator, + source: feed.source, + sourceUrl: feed.sourceUrl, + ...parsed, + }; + cache.set(rto, { at: Date.now(), snap }); + return snap; + } catch (e) { + // A recent stale snapshot beats an error page; older than that, be honest. + if (hit && Date.now() - hit.at < STALE_OK_MS) return hit.snap; + throw e; + } +} + +/** Test seam. */ +export function clearGridLiveCache(): void { + cache.clear(); +} diff --git a/server/routes.ts b/server/routes.ts index 0cf0036..21f6459 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1,4 +1,5 @@ import { averageLiveChanges } from "./pulse-math"; +import { getGridLive, isLiveRto, LIVE_RTOS } from "./grid-live"; import { fetchWithTimeout } from "./fetch-timeout"; import type { Express, Request, Response } from "express"; import { type Server } from "http"; @@ -2208,6 +2209,26 @@ export async function registerRoutes( } }); + // Live grid snapshot for My Grid: today's demand curve + current fuel mix + // from the ISO's own keyless public feeds (5-minute data), cached 5 min + // server-side. Public, read-only. Unsupported regions 404 with the list. + app.get("/api/grid-live/:rto", async (req, res) => { + const rto = String(req.params.rto).toLowerCase(); + if (!isLiveRto(rto)) { + return res.status(404).json({ error: "No live feed for that region", supported: LIVE_RTOS }); + } + try { + res.json(await getGridLive(rto)); + } catch (error) { + console.error(`grid-live ${rto} error:`, error); + res.status(503).json({ + error: "Live feed unavailable right now", + rto, + detail: String(error).slice(0, 200), + }); + } + }); + // Hyperscaler capex aggregate for the homepage explainer. Public. app.get("/api/hyperscaler-capex", (_req, res) => { try { From a0295e1f699a93129408ee382e8b233e5bb0820d Mon Sep 17 00:00:00 2001 From: aurph Date: Thu, 3 Sep 2026 11:50:52 -0400 Subject: [PATCH 2/3] routes: move grid-live import off the shared anchor line PR #27 inserts its subscriber-store import between the same two lines; whichever merged second would conflict. Anchoring after fetch-timeout makes #27 and #28 merge cleanly in either order. --- server/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routes.ts b/server/routes.ts index 21f6459..65a788b 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1,6 +1,6 @@ import { averageLiveChanges } from "./pulse-math"; -import { getGridLive, isLiveRto, LIVE_RTOS } from "./grid-live"; import { fetchWithTimeout } from "./fetch-timeout"; +import { getGridLive, isLiveRto, LIVE_RTOS } from "./grid-live"; import type { Express, Request, Response } from "express"; import { type Server } from "http"; import { readFileSync, writeFileSync, existsSync } from "fs"; From 43e9826c32ad778a2d9ee0754bc36e0c1f5d7cee Mon Sep 17 00:00:00 2001 From: aurph Date: Thu, 3 Sep 2026 11:50:03 -0400 Subject: [PATCH 3/3] deps: clear the browserslist + postcss-selector-parser advisories Two advisories published upstream since the last merge (browserslist unbounded memory growth, HIGH; postcss-selector-parser AST recursion, low) trip CI's npm-audit gate on every new push, blocking the whole open PR train. npm audit fix; 0 vulnerabilities after; tests/tsc/build green. Lockfile-only. --- package-lock.json | 104 +++++++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19dc994..af9d835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3117,6 +3117,19 @@ "node": ">= 0.4" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3205,9 +3218,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -3225,10 +3238,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -3294,9 +3308,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001753", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", - "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -4100,9 +4114,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.51", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.51.tgz", - "integrity": "sha512-kKeWV57KSS8jH4alKt/jKnvHPmJgBxXzGUSbMd4eQF+iOsVPl7bz2KUmu6eo80eMP8wVioTfTyTzdMgM15WXNg==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -4158,9 +4172,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4382,9 +4396,9 @@ } }, "node_modules/fflate": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz", + "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==", "license": "MIT" }, "node_modules/filename-reserved-regex": { @@ -5237,11 +5251,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -5561,9 +5578,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5629,12 +5646,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6256,14 +6274,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -6275,13 +6293,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -6862,9 +6880,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -6883,7 +6901,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js"