diff --git a/.claude/settings.json b/.claude/settings.json index 7725b8a..6e84945 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,6 @@ { "enabledPlugins": { - "ralph-loop@claude-plugins-official": true + "ralph-loop@claude-plugins-official": true, + "typescript-lsp@claude-plugins-official": false } } diff --git a/prisma/sql/getCapacityPriceTimeline.sql b/prisma/sql/getCapacityPriceTimeline.sql new file mode 100644 index 0000000..516d96f --- /dev/null +++ b/prisma/sql/getCapacityPriceTimeline.sql @@ -0,0 +1,39 @@ +-- @param {String} $1:regionCode +-- Monthly average electricity price and cumulative datacenter capacity for a region +WITH months AS ( + SELECT generate_series( + '2019-01-01'::timestamptz, + date_trunc('month', now()), + '1 month' + ) AS month +), +monthly_prices AS ( + SELECT + date_trunc('month', ep.timestamp) AS month, + AVG(ep.price_mwh) AS avg_price + FROM electricity_prices ep + JOIN grid_regions r ON ep.region_id = r.id + WHERE r.code = $1 + GROUP BY 1 +), +dc_capacity AS ( + SELECT + make_timestamptz(d.year_opened, 1, 1, 0, 0, 0) AS opened_month, + SUM(d.capacity_mw) AS added_mw + FROM datacenters d + JOIN grid_regions r ON d.region_id = r.id + WHERE r.code = $1 + GROUP BY d.year_opened +) +SELECT + m.month, + mp.avg_price, + COALESCE( + SUM(dc.added_mw) OVER (ORDER BY m.month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), + 0 + )::float AS cumulative_capacity_mw +FROM months m +LEFT JOIN monthly_prices mp ON mp.month = m.month +LEFT JOIN dc_capacity dc ON dc.opened_month <= m.month + AND dc.opened_month > m.month - INTERVAL '1 month' +ORDER BY m.month ASC diff --git a/prisma/sql/getDcPriceImpact.sql b/prisma/sql/getDcPriceImpact.sql new file mode 100644 index 0000000..5e84614 --- /dev/null +++ b/prisma/sql/getDcPriceImpact.sql @@ -0,0 +1,54 @@ +-- Before/after price comparison for each datacenter opening event (year_opened >= 2019) +WITH dc_events AS ( + SELECT + d.name AS dc_name, + d.capacity_mw, + d.year_opened, + r.code AS region_code, + r.name AS region_name, + make_timestamptz(d.year_opened, 1, 1, 0, 0, 0) AS event_date + FROM datacenters d + JOIN grid_regions r ON d.region_id = r.id + WHERE d.year_opened >= 2019 +), +price_before AS ( + SELECT + de.dc_name, + AVG(ep.price_mwh) AS avg_price_before + FROM dc_events de + JOIN grid_regions r ON r.code = de.region_code + JOIN electricity_prices ep ON ep.region_id = r.id + AND ep.timestamp >= de.event_date - INTERVAL '6 months' + AND ep.timestamp < de.event_date + GROUP BY de.dc_name +), +price_after AS ( + SELECT + de.dc_name, + AVG(ep.price_mwh) AS avg_price_after + FROM dc_events de + JOIN grid_regions r ON r.code = de.region_code + JOIN electricity_prices ep ON ep.region_id = r.id + AND ep.timestamp >= de.event_date + AND ep.timestamp < de.event_date + INTERVAL '6 months' + GROUP BY de.dc_name +) +SELECT + de.dc_name, + de.capacity_mw, + de.year_opened, + de.region_code, + de.region_name, + pb.avg_price_before, + pa.avg_price_after, + CASE + WHEN pb.avg_price_before > 0 + THEN ((pa.avg_price_after - pb.avg_price_before) / pb.avg_price_before * 100) + ELSE NULL + END AS pct_change +FROM dc_events de +LEFT JOIN price_before pb ON pb.dc_name = de.dc_name +LEFT JOIN price_after pa ON pa.dc_name = de.dc_name +WHERE pb.avg_price_before IS NOT NULL + AND pa.avg_price_after IS NOT NULL +ORDER BY ABS(COALESCE(pa.avg_price_after - pb.avg_price_before, 0)) DESC diff --git a/prisma/sql/getLatestPrices.sql b/prisma/sql/getLatestPrices.sql index b2b1702..a88377c 100644 --- a/prisma/sql/getLatestPrices.sql +++ b/prisma/sql/getLatestPrices.sql @@ -1,6 +1,20 @@ -SELECT DISTINCT ON (ep.region_id) - ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source, - r.code as region_code, r.name as region_name -FROM electricity_prices ep -JOIN grid_regions r ON ep.region_id = r.id -ORDER BY ep.region_id, ep.timestamp DESC +SELECT + latest.id, latest.region_id, latest.price_mwh, latest.demand_mw, + latest.timestamp, latest.source, + r.code as region_code, r.name as region_name, + stats.avg_price_7d, stats.stddev_price_7d +FROM ( + SELECT DISTINCT ON (ep.region_id) + ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source + FROM electricity_prices ep + ORDER BY ep.region_id, ep.timestamp DESC +) latest +JOIN grid_regions r ON latest.region_id = r.id +LEFT JOIN LATERAL ( + SELECT + AVG(ep2.price_mwh)::double precision as avg_price_7d, + STDDEV(ep2.price_mwh)::double precision as stddev_price_7d + FROM electricity_prices ep2 + WHERE ep2.region_id = latest.region_id + AND ep2.timestamp >= NOW() - INTERVAL '7 days' +) stats ON true diff --git a/src/actions/dc-impact.ts b/src/actions/dc-impact.ts new file mode 100644 index 0000000..3f8867c --- /dev/null +++ b/src/actions/dc-impact.ts @@ -0,0 +1,56 @@ +'use server'; + +import { getCapacityPriceTimeline, getDcPriceImpact } from '@/generated/prisma/sql.js'; +import { prisma } from '@/lib/db.js'; +import { serialize } from '@/lib/superjson.js'; +import { validateRegionCode } from '@/lib/utils.js'; +import { cacheLife, cacheTag } from 'next/cache'; + +interface ActionSuccess { + ok: true; + data: ReturnType>; +} + +interface ActionError { + ok: false; + error: string; +} + +type ActionResult = ActionSuccess | ActionError; + +export async function fetchCapacityPriceTimeline( + regionCode: string, +): Promise> { + 'use cache'; + cacheLife('prices'); + cacheTag(`capacity-price-timeline-${regionCode}`); + + try { + if (!validateRegionCode(regionCode)) { + return { ok: false, error: `Invalid region code: ${regionCode}` }; + } + const rows = await prisma.$queryRawTyped(getCapacityPriceTimeline(regionCode)); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch capacity/price timeline: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +export async function fetchDcPriceImpact(): Promise> { + 'use cache'; + cacheLife('prices'); + cacheTag('dc-price-impact'); + + try { + const rows = await prisma.$queryRawTyped(getDcPriceImpact()); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch DC price impact: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} diff --git a/src/actions/demand.ts b/src/actions/demand.ts index 206bab7..5bec207 100644 --- a/src/actions/demand.ts +++ b/src/actions/demand.ts @@ -7,16 +7,18 @@ import { serialize } from '@/lib/superjson.js'; import { validateRegionCode } from '@/lib/utils.js'; import { cacheLife, cacheTag } from 'next/cache'; -type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; +type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all'; function timeRangeToStartDate(range: TimeRange): Date { + if (range === 'all') return new Date('2019-01-01T00:00:00Z'); const now = new Date(); - const ms: Record = { + const ms: Record, number> = { '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000, '1y': 365 * 24 * 60 * 60 * 1000, + '5y': 5 * 365 * 24 * 60 * 60 * 1000, }; return new Date(now.getTime() - ms[range]); } diff --git a/src/actions/generation.ts b/src/actions/generation.ts index 2643324..d0cb751 100644 --- a/src/actions/generation.ts +++ b/src/actions/generation.ts @@ -7,16 +7,18 @@ import { serialize } from '@/lib/superjson.js'; import { validateRegionCode } from '@/lib/utils.js'; import { cacheLife, cacheTag } from 'next/cache'; -type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; +type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all'; function timeRangeToStartDate(range: TimeRange): Date { + if (range === 'all') return new Date('2019-01-01T00:00:00Z'); const now = new Date(); - const ms: Record = { + const ms: Record, number> = { '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000, '1y': 365 * 24 * 60 * 60 * 1000, + '5y': 5 * 365 * 24 * 60 * 60 * 1000, }; return new Date(now.getTime() - ms[range]); } diff --git a/src/actions/prices.ts b/src/actions/prices.ts index f63d752..6debc49 100644 --- a/src/actions/prices.ts +++ b/src/actions/prices.ts @@ -13,16 +13,18 @@ import { serialize } from '@/lib/superjson.js'; import { validateRegionCode } from '@/lib/utils.js'; import { cacheLife, cacheTag } from 'next/cache'; -type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; +type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all'; function timeRangeToStartDate(range: TimeRange): Date { + if (range === 'all') return new Date('2019-01-01T00:00:00Z'); const now = new Date(); - const ms: Record = { + const ms: Record, number> = { '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000, '1y': 365 * 24 * 60 * 60 * 1000, + '5y': 5 * 365 * 24 * 60 * 60 * 1000, }; return new Date(now.getTime() - ms[range]); } @@ -293,55 +295,68 @@ export async function fetchRecentAlerts(): Promise< timestamp: Date; }> = []; - // Detect price spikes (above $80/MWh) and demand peaks - const regionAvgs = new Map(); + // Compute per-region mean and stddev for statistical anomaly detection + const regionStats = new Map(); for (const row of priceRows) { - const entry = regionAvgs.get(row.region.code) ?? { sum: 0, count: 0 }; + const entry = regionStats.get(row.region.code) ?? { sum: 0, sumSq: 0, count: 0 }; entry.sum += row.priceMwh; + entry.sumSq += row.priceMwh * row.priceMwh; entry.count += 1; - regionAvgs.set(row.region.code, entry); + regionStats.set(row.region.code, entry); + } + + function getRegionThresholds(regionCode: string) { + const stats = regionStats.get(regionCode); + if (!stats || stats.count < 2) return null; + const avg = stats.sum / stats.count; + const variance = stats.sumSq / stats.count - avg * avg; + const sd = Math.sqrt(Math.max(0, variance)); + return { avg, sd }; } for (const row of priceRows) { - const avg = regionAvgs.get(row.region.code); - const avgPrice = avg ? avg.sum / avg.count : 0; + const thresholds = getRegionThresholds(row.region.code); + if (thresholds && thresholds.sd > 0) { + const { avg, sd } = thresholds; + const sigmas = (row.priceMwh - avg) / sd; - if (row.priceMwh >= 100) { - alerts.push({ - id: `spike-${row.id}`, - type: 'price_spike', - severity: 'critical', - region_code: row.region.code, - region_name: row.region.name, - description: `${row.region.code} electricity hit $${row.priceMwh.toFixed(2)}/MWh`, - value: row.priceMwh, - unit: '$/MWh', - timestamp: row.timestamp, - }); - } else if (row.priceMwh >= 80) { - alerts.push({ - id: `spike-${row.id}`, - type: 'price_spike', - severity: 'warning', - region_code: row.region.code, - region_name: row.region.name, - description: `${row.region.code} electricity at $${row.priceMwh.toFixed(2)}/MWh`, - value: row.priceMwh, - unit: '$/MWh', - timestamp: row.timestamp, - }); - } else if (avgPrice > 0 && row.priceMwh < avgPrice * 0.7) { - alerts.push({ - id: `drop-${row.id}`, - type: 'price_drop', - severity: 'info', - region_code: row.region.code, - region_name: row.region.name, - description: `${row.region.code} price dropped to $${row.priceMwh.toFixed(2)}/MWh (avg $${avgPrice.toFixed(2)})`, - value: row.priceMwh, - unit: '$/MWh', - timestamp: row.timestamp, - }); + if (sigmas >= 2.5) { + alerts.push({ + id: `spike-${row.id}`, + type: 'price_spike', + severity: 'critical', + region_code: row.region.code, + region_name: row.region.name, + description: `${row.region.code} at $${row.priceMwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`, + value: row.priceMwh, + unit: '$/MWh', + timestamp: row.timestamp, + }); + } else if (sigmas >= 1.8) { + alerts.push({ + id: `spike-${row.id}`, + type: 'price_spike', + severity: 'warning', + region_code: row.region.code, + region_name: row.region.name, + description: `${row.region.code} at $${row.priceMwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`, + value: row.priceMwh, + unit: '$/MWh', + timestamp: row.timestamp, + }); + } else if (sigmas <= -1.8) { + alerts.push({ + id: `drop-${row.id}`, + type: 'price_drop', + severity: 'info', + region_code: row.region.code, + region_name: row.region.name, + description: `${row.region.code} dropped to $${row.priceMwh.toFixed(2)}/MWh — ${Math.abs(sigmas).toFixed(1)}σ below avg ($${avg.toFixed(0)})`, + value: row.priceMwh, + unit: '$/MWh', + timestamp: row.timestamp, + }); + } } if (row.demandMw >= 50000) { diff --git a/src/app/_sections/demand-summary.tsx b/src/app/_sections/demand-summary.tsx index 956bb01..bdb187d 100644 --- a/src/app/_sections/demand-summary.tsx +++ b/src/app/_sections/demand-summary.tsx @@ -92,6 +92,12 @@ export async function DemandSummary() { }, ]; + // Top 5 regions by demand for a quick leaderboard + const topRegions = [...regionMap.values()] + .filter(r => r.latestDemandMw > 0) + .sort((a, b) => b.latestDemandMw - a.latestDemandMw) + .slice(0, 5); + return ( @@ -113,6 +119,31 @@ export async function DemandSummary() { ))} + + {topRegions.length > 0 && ( + <> +
+

Top Regions by Demand

+
+ {topRegions.map((r, i) => { + const maxDemand = topRegions[0]!.latestDemandMw; + const pct = maxDemand > 0 ? (r.latestDemandMw / maxDemand) * 100 : 0; + return ( +
+ {i + 1} + {r.regionCode} +
+
+
+ + {formatGw(r.latestDemandMw)} {formatGwUnit(r.latestDemandMw)} + +
+ ); + })} +
+ + )} ); diff --git a/src/app/_sections/gpu-calculator-section.tsx b/src/app/_sections/gpu-calculator-section.tsx index b4fbc67..4527867 100644 --- a/src/app/_sections/gpu-calculator-section.tsx +++ b/src/app/_sections/gpu-calculator-section.tsx @@ -24,9 +24,5 @@ export async function GpuCalculatorSection() { if (regionPrices.length === 0) return null; - return ( -
- -
- ); + return ; } diff --git a/src/app/_sections/prices-by-region.tsx b/src/app/_sections/prices-by-region.tsx index c35dadd..8191166 100644 --- a/src/app/_sections/prices-by-region.tsx +++ b/src/app/_sections/prices-by-region.tsx @@ -37,7 +37,7 @@ export async function PricesByRegion() { {prices.length > 0 ? ( -
+
{prices.map(p => { const regionSparkline = sparklineMap[p.region_code]; return ( diff --git a/src/app/_sections/stress-gauges.tsx b/src/app/_sections/stress-gauges.tsx index e328af4..c9ac7c7 100644 --- a/src/app/_sections/stress-gauges.tsx +++ b/src/app/_sections/stress-gauges.tsx @@ -61,42 +61,40 @@ export async function StressGauges() { const rightColumn = regions.slice(midpoint); return ( -
- - - - - Region Grid Status - -

Current demand vs. 7-day peak by region

-
- -
-
- {leftColumn.map(r => ( - - ))} -
-
- {rightColumn.map(r => ( - - ))} -
+ + + + + Region Grid Status + +

Current demand vs. 7-day peak by region

+
+ +
+
+ {leftColumn.map(r => ( + + ))}
- - -
+
+ {rightColumn.map(r => ( + + ))} +
+
+
+
); } diff --git a/src/app/api/ingest/commodities/route.ts b/src/app/api/ingest/commodities/route.ts index 072b605..0d322b6 100644 --- a/src/app/api/ingest/commodities/route.ts +++ b/src/app/api/ingest/commodities/route.ts @@ -118,9 +118,19 @@ export async function GET(request: NextRequest): Promise { if (authError) return authError; const searchParams = request.nextUrl.searchParams; - const start = searchParams.get('start') ?? undefined; const end = searchParams.get('end') ?? undefined; + // Default to last 30 days if no start date provided — commodity data + // is daily/monthly so a wider window is fine and still bounded. + const startParam = searchParams.get('start'); + const start = + startParam ?? + (() => { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 30); + return d.toISOString().slice(0, 10); + })(); + const stats: IngestionStats = { inserted: 0, updated: 0, errors: 0 }; const { rows, errors: fetchErrors } = await fetchAllCommodities(start, end); diff --git a/src/app/api/ingest/electricity/route.ts b/src/app/api/ingest/electricity/route.ts index 323bb1e..7cc199e 100644 --- a/src/app/api/ingest/electricity/route.ts +++ b/src/app/api/ingest/electricity/route.ts @@ -38,9 +38,19 @@ export async function GET(request: NextRequest): Promise { const searchParams = request.nextUrl.searchParams; const regionParam = searchParams.get('region'); - const start = searchParams.get('start') ?? undefined; const end = searchParams.get('end') ?? undefined; + // Default to last 7 days if no start date provided — prevents + // auto-paginating through ALL historical EIA data (causes timeouts). + const startParam = searchParams.get('start'); + const start = + startParam ?? + (() => { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 7); + return d.toISOString().slice(0, 10); + })(); + let regions: RegionCode[]; if (regionParam) { if (!isRegionCode(regionParam)) { @@ -73,7 +83,7 @@ export async function GET(request: NextRequest): Promise { // Continue with demand data even if prices fail } - // Build fallback: for each region, find the most recent month with data + // Build fallback: for each region, find the most recent month with data from the API const latestPriceByRegion = new Map(); for (const [key, price] of retailPriceByRegionMonth) { const region = key.split(':')[0]!; @@ -83,6 +93,29 @@ export async function GET(request: NextRequest): Promise { } } + // If API returned no prices (e.g. recent months not yet reported), + // fall back to the last known non-zero price per region from the database. + if (retailPriceByRegionMonth.size === 0) { + const dbFallbacks = await prisma.$queryRaw>` + SELECT r.code, ep.price_mwh + FROM electricity_prices ep + JOIN grid_regions r ON ep.region_id = r.id + WHERE ep.price_mwh > 0 + AND (r.code, ep.timestamp) IN ( + SELECT r2.code, MAX(ep2.timestamp) + FROM electricity_prices ep2 + JOIN grid_regions r2 ON ep2.region_id = r2.id + WHERE ep2.price_mwh > 0 + GROUP BY r2.code + ) + `; + for (const row of dbFallbacks) { + if (!latestPriceByRegion.has(row.code)) { + latestPriceByRegion.set(row.code, row.price_mwh); + } + } + } + for (const regionCode of regions) { const regionId = regionIdByCode.get(regionCode); if (!regionId) { @@ -92,7 +125,12 @@ export async function GET(request: NextRequest): Promise { try { const demandData = await getRegionData(regionCode, 'D', { start, end }); - const validPoints = demandData.filter((p): p is typeof p & { valueMw: number } => p.valueMw !== null); + // Reject values outside a reasonable range — the EIA API occasionally returns + // garbage (e.g. 2^31 overflow, deeply negative values). PJM peak is ~150K MW. + const MAX_DEMAND_MW = 500_000; + const validPoints = demandData.filter( + (p): p is typeof p & { valueMw: number } => p.valueMw !== null && p.valueMw >= 0 && p.valueMw <= MAX_DEMAND_MW, + ); if (validPoints.length === 0) continue; diff --git a/src/app/api/ingest/generation/route.ts b/src/app/api/ingest/generation/route.ts index 47d43b6..5bb2e83 100644 --- a/src/app/api/ingest/generation/route.ts +++ b/src/app/api/ingest/generation/route.ts @@ -38,9 +38,19 @@ export async function GET(request: NextRequest): Promise { const searchParams = request.nextUrl.searchParams; const regionParam = searchParams.get('region'); - const start = searchParams.get('start') ?? undefined; const end = searchParams.get('end') ?? undefined; + // Default to last 7 days if no start date provided — prevents + // auto-paginating through ALL historical EIA data (causes timeouts). + const startParam = searchParams.get('start'); + const start = + startParam ?? + (() => { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 7); + return d.toISOString().slice(0, 10); + })(); + let regions: RegionCode[]; if (regionParam) { if (!isRegionCode(regionParam)) { diff --git a/src/app/demand/_sections/demand-chart-section.tsx b/src/app/demand/_sections/demand-chart-section.tsx index c228a46..5cb46b4 100644 --- a/src/app/demand/_sections/demand-chart-section.tsx +++ b/src/app/demand/_sections/demand-chart-section.tsx @@ -5,7 +5,7 @@ import { deserialize } from '@/lib/superjson.js'; export async function DemandChartSection() { const [demandResult, summaryResult] = await Promise.all([ - fetchDemandByRegion('ALL', '30d'), + fetchDemandByRegion('ALL', 'all'), fetchRegionDemandSummary(), ]); diff --git a/src/app/generation/_sections/generation-chart-section.tsx b/src/app/generation/_sections/generation-chart-section.tsx index 4bb2b71..84feb30 100644 --- a/src/app/generation/_sections/generation-chart-section.tsx +++ b/src/app/generation/_sections/generation-chart-section.tsx @@ -2,7 +2,7 @@ import { fetchGenerationMix } from '@/actions/generation.js'; import { GenerationChart } from '@/components/charts/generation-chart.js'; const DEFAULT_REGION = 'PJM'; -const DEFAULT_TIME_RANGE = '30d' as const; +const DEFAULT_TIME_RANGE = 'all' as const; export async function GenerationChartSection() { const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4d7ec4b..a4d4b30 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,10 +23,10 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - +