- Replace $0 electricity prices with real EIA retail-sales data (IND sector) with demand-based hourly variation (0.8x-1.2x) - Add sparkline component and alerts feed to dashboard home - Add animated number transitions to hero metric cards - Fix ticker tape price direction (green/red arrows with % change) - Fix AI milestone annotation alignment on price charts - Fix SQL cartesian products in getDemandByRegion and getRegionPriceHeatmap using CTEs for independent aggregation - Add unique composite constraints to prevent duplicate data - Add bearer token auth to ingestion API routes - Add 30s fetch timeouts to EIA and FRED API clients - Add regionCode validation in server actions - Fix docker-compose: localhost-only port binding, correct volume path - Fix seed script to preserve ingested time-series data
417 lines
12 KiB
TypeScript
417 lines
12 KiB
TypeScript
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<string, string[]>;
|
|
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<unknown> {
|
|
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<T>(
|
|
endpoint: string,
|
|
params: EiaQueryParams,
|
|
parseResponse: (json: unknown) => { total: number; data: T[] },
|
|
): Promise<T[]> {
|
|
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<RegionDataPoint[]> {
|
|
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<FuelTypeDataPoint[]> {
|
|
const respondentCode = EIA_RESPONDENT_CODES[regionCode];
|
|
|
|
const facets: Record<string, string[]> = {
|
|
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<CommodityPricePoint[]> {
|
|
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<CommodityPricePoint[]> {
|
|
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<RetailPricePoint[]> {
|
|
const stateIds = Object.values(REGION_STATE_MAP);
|
|
const regionCodes: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'];
|
|
const stateToRegion = new Map<string, RegionCode>();
|
|
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;
|
|
}
|