import { type CommodityPricePoint, eiaNaturalGasResponseSchema, eiaWtiCrudeResponseSchema, parseEiaCommodityPeriod, } from '@/lib/schemas/commodities.js'; import { EIA_RESPONDENT_CODES, type EiaFuelTypeDataRow, type EiaRegionDataRow, type FuelTypeDataPoint, REGION_STATE_MAP, type RegionCode, type RegionDataPoint, type RetailPricePoint, eiaFuelTypeDataResponseSchema, eiaRegionDataResponseSchema, eiaRetailPriceResponseSchema, parseEiaPeriod, resolveRegionCode, } from '@/lib/schemas/electricity.js'; const EIA_BASE_URL = 'https://api.eia.gov/v2'; const MAX_ROWS_PER_REQUEST = 5000; function getApiKey(): string { const key = process.env.EIA_API_KEY; if (!key) { throw new Error('EIA_API_KEY environment variable is not set'); } return key; } interface EiaQueryParams { frequency?: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'annual'; start?: string; end?: string; facets?: Record; sort?: Array<{ column: string; direction: 'asc' | 'desc' }>; offset?: number; length?: number; /** Data column name(s) to request. Defaults to ['value']. */ dataColumns?: string[]; } function buildUrl(endpoint: string, params: EiaQueryParams): string { const url = new URL(`${EIA_BASE_URL}${endpoint}`); url.searchParams.set('api_key', getApiKey()); const columns = params.dataColumns ?? ['value']; for (let i = 0; i < columns.length; i++) { url.searchParams.set(`data[${i}]`, columns[i]!); } if (params.frequency) { url.searchParams.set('frequency', params.frequency); } if (params.start) { url.searchParams.set('start', params.start); } if (params.end) { url.searchParams.set('end', params.end); } if (params.facets) { for (const [key, values] of Object.entries(params.facets)) { for (const value of values) { url.searchParams.append(`facets[${key}][]`, value); } } } if (params.sort) { for (let i = 0; i < params.sort.length; i++) { const s = params.sort[i]; if (s) { url.searchParams.set(`sort[${i}][column]`, s.column); url.searchParams.set(`sort[${i}][direction]`, s.direction); } } } url.searchParams.set('offset', String(params.offset ?? 0)); url.searchParams.set('length', String(params.length ?? MAX_ROWS_PER_REQUEST)); return url.toString(); } const FETCH_TIMEOUT_MS = 30_000; async function fetchEia(endpoint: string, params: EiaQueryParams): Promise { const url = buildUrl(endpoint, params); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) { const text = await response.text().catch(() => 'unknown error'); throw new Error(`EIA API error ${response.status}: ${text}`); } return response.json(); } finally { clearTimeout(timeoutId); } } /** * Auto-paginate through an EIA endpoint, collecting all rows. * EIA limits responses to 5000 rows — this fetches all pages sequentially. */ async function fetchAllPages( endpoint: string, params: EiaQueryParams, parseResponse: (json: unknown) => { total: number; data: T[] }, ): Promise { const allData: T[] = []; let offset = params.offset ?? 0; const length = params.length ?? MAX_ROWS_PER_REQUEST; for (;;) { const json = await fetchEia(endpoint, { ...params, offset, length }); const result = parseResponse(json); allData.push(...result.data); if (allData.length >= result.total || result.data.length < length) { break; } offset += length; } return allData; } /** Convert a raw EIA region-data row into a typed RegionDataPoint */ function transformRegionDataRow(row: EiaRegionDataRow): RegionDataPoint { const regionCode = resolveRegionCode(row.respondent); return { timestamp: parseEiaPeriod(row.period), regionCode: regionCode ?? 'PJM', respondent: row.respondent, type: row.type, typeName: row['type-name'], valueMw: row.value, valueUnits: row['value-units'], }; } /** Convert a raw EIA fuel-type-data row into a typed FuelTypeDataPoint */ function transformFuelTypeRow(row: EiaFuelTypeDataRow): FuelTypeDataPoint { const regionCode = resolveRegionCode(row.respondent); return { timestamp: parseEiaPeriod(row.period), regionCode: regionCode ?? 'PJM', respondent: row.respondent, fuelType: row.fueltype, typeName: row['type-name'], generationMw: row.value, valueUnits: row['value-units'], }; } export interface GetRegionDataOptions { start?: string; end?: string; /** Maximum number of rows to fetch. Omit for all available data. */ limit?: number; } /** * Fetch hourly demand or net generation data for a region. * * @param regionCode - One of PJM, ERCOT, CAISO, NYISO, ISONE, MISO, SPP * @param type - "D" for demand, "NG" for net generation * * NOTE: EIA does not provide real-time wholesale electricity prices. * The /v2/electricity/rto/region-data/data/ endpoint provides demand (MW) and * net generation (MW), NOT price ($/MWh). For the electricity_prices table, * we store demand_mw from this data; price_mwh will be null. * TODO: Investigate scraping ISO-specific price feeds (PJM LMP, ERCOT SPP, CAISO LMP) * for real wholesale price data if needed in the future. */ export async function getRegionData( regionCode: RegionCode, type: 'D' | 'NG', options: GetRegionDataOptions = {}, ): Promise { const respondentCode = EIA_RESPONDENT_CODES[regionCode]; const params: EiaQueryParams = { frequency: 'hourly', start: options.start, end: options.end, facets: { respondent: [respondentCode], type: [type], }, sort: [{ column: 'period', direction: 'desc' }], length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST, }; if (options.limit && options.limit <= MAX_ROWS_PER_REQUEST) { const json = await fetchEia('/electricity/rto/region-data/data/', params); const parsed = eiaRegionDataResponseSchema.parse(json); return parsed.response.data.map(transformRegionDataRow); } const rows = await fetchAllPages('/electricity/rto/region-data/data/', params, json => { const parsed = eiaRegionDataResponseSchema.parse(json); return { total: parsed.response.total, data: parsed.response.data }; }); return rows.map(transformRegionDataRow); } export interface GetFuelTypeDataOptions { start?: string; end?: string; fuelType?: string; limit?: number; } /** * Fetch hourly generation by fuel type for a region. * * @param regionCode - One of PJM, ERCOT, CAISO, NYISO, ISONE, MISO, SPP * @param options - Optional filters for time range and fuel type */ export async function getFuelTypeData( regionCode: RegionCode, options: GetFuelTypeDataOptions = {}, ): Promise { const respondentCode = EIA_RESPONDENT_CODES[regionCode]; const facets: Record = { respondent: [respondentCode], }; if (options.fuelType) { facets['fueltype'] = [options.fuelType]; } const params: EiaQueryParams = { frequency: 'hourly', start: options.start, end: options.end, facets, sort: [{ column: 'period', direction: 'desc' }], length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST, }; if (options.limit && options.limit <= MAX_ROWS_PER_REQUEST) { const json = await fetchEia('/electricity/rto/fuel-type-data/data/', params); const parsed = eiaFuelTypeDataResponseSchema.parse(json); return parsed.response.data.map(transformFuelTypeRow); } const rows = await fetchAllPages('/electricity/rto/fuel-type-data/data/', params, json => { const parsed = eiaFuelTypeDataResponseSchema.parse(json); return { total: parsed.response.total, data: parsed.response.data }; }); return rows.map(transformFuelTypeRow); } export interface GetCommodityPriceOptions { start?: string; end?: string; limit?: number; } /** * Fetch Henry Hub natural gas spot prices. * Endpoint: /v2/natural-gas/pri/fut/data/ with facets[series][]=RNGWHHD */ export async function getNaturalGasPrice(options: GetCommodityPriceOptions = {}): Promise { const params: EiaQueryParams = { frequency: 'daily', start: options.start, end: options.end, facets: { series: ['RNGWHHD'], }, sort: [{ column: 'period', direction: 'desc' }], length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST, }; if (options.limit && options.limit <= MAX_ROWS_PER_REQUEST) { const json = await fetchEia('/natural-gas/pri/fut/data/', params); const parsed = eiaNaturalGasResponseSchema.parse(json); return parsed.response.data.map(row => ({ timestamp: parseEiaCommodityPeriod(row.period), commodity: 'natural_gas' as const, price: row.value, unit: row.units ?? '$/Million BTU', source: 'EIA', })); } const rows = await fetchAllPages('/natural-gas/pri/fut/data/', params, json => { const parsed = eiaNaturalGasResponseSchema.parse(json); return { total: parsed.response.total, data: parsed.response.data }; }); return rows.map(row => ({ timestamp: parseEiaCommodityPeriod(row.period), commodity: 'natural_gas' as const, price: row.value, unit: row.units ?? '$/Million BTU', source: 'EIA', })); } /** * Fetch WTI crude oil spot prices. * Endpoint: /v2/petroleum/pri/spt/data/ with facets[series][]=RWTC */ export async function getWTICrudePrice(options: GetCommodityPriceOptions = {}): Promise { const params: EiaQueryParams = { frequency: 'daily', start: options.start, end: options.end, facets: { series: ['RWTC'], }, sort: [{ column: 'period', direction: 'desc' }], length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST, }; if (options.limit && options.limit <= MAX_ROWS_PER_REQUEST) { const json = await fetchEia('/petroleum/pri/spt/data/', params); const parsed = eiaWtiCrudeResponseSchema.parse(json); return parsed.response.data.map(row => ({ timestamp: parseEiaCommodityPeriod(row.period), commodity: 'wti_crude' as const, price: row.value, unit: row.units ?? '$/Barrel', source: 'EIA', })); } const rows = await fetchAllPages('/petroleum/pri/spt/data/', params, json => { const parsed = eiaWtiCrudeResponseSchema.parse(json); return { total: parsed.response.total, data: parsed.response.data }; }); return rows.map(row => ({ timestamp: parseEiaCommodityPeriod(row.period), commodity: 'wti_crude' as const, price: row.value, unit: row.units ?? '$/Barrel', source: 'EIA', })); } // --------------------------------------------------------------------------- // Retail electricity prices (monthly, by state) // --------------------------------------------------------------------------- export interface GetRetailPriceOptions { /** Start date in YYYY-MM format */ start?: string; /** End date in YYYY-MM format */ end?: string; } /** * Fetch monthly retail electricity prices for industrial (IND) sector. * Returns prices for all 7 tracked regions, mapped via REGION_STATE_MAP. * * Endpoint: /v2/electricity/retail-sales/data/ * Price is returned in cents/kWh; we convert to $/MWh (* 10). */ export async function getRetailElectricityPrices(options: GetRetailPriceOptions = {}): Promise { const stateIds = Object.values(REGION_STATE_MAP); const regionCodes: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP']; const stateToRegion = new Map(); for (const region of regionCodes) { stateToRegion.set(REGION_STATE_MAP[region], region); } const params: EiaQueryParams = { frequency: 'monthly', start: options.start, end: options.end, facets: { sectorid: ['IND'], stateid: stateIds, }, sort: [{ column: 'period', direction: 'desc' }], dataColumns: ['price'], }; const rows = await fetchAllPages('/electricity/retail-sales/data/', params, json => { const parsed = eiaRetailPriceResponseSchema.parse(json); return { total: parsed.response.total, data: parsed.response.data }; }); const results: RetailPricePoint[] = []; for (const row of rows) { if (row.price === null) continue; const regionCode = stateToRegion.get(row.stateid); if (!regionCode) continue; results.push({ period: row.period, stateId: row.stateid, regionCode, priceMwh: row.price * 10, // cents/kWh -> $/MWh }); } return results; }